Merge remote-tracking branch 'origin/main' into codex/housekeeping-rebuild-stepwise

# Conflicts:
#	.env.example
#	src/app/api/admin/import/furni/route.ts
#	src/components/admin/studio/studio-client.tsx
#	src/lib/services/furni-import.ts
#	src/lib/services/import/core/download.ts
This commit is contained in:
Simo committed 2026-09-05 15:54:41 +02:00
commit 9b83cdc3c3
94 files changed
+5568 -644

No files matched your search

+5
View File
@@ -76,3 +76,8 @@ FLARESOLVERR_URL=http://localhost:8191
# Gated Housekeeping preview; never enabled in production
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
# Catalog Studio export: dedicated clean clone on Beta-3 with Git push credentials.
CATALOG_GIT_CHECKOUT=
# Persistent directory shared by CMS and worker, outside the catalog clone.
CATALOG_GIT_STATE_DIR=
+73
View File
@@ -0,0 +1,73 @@
# Catalog Studio repository export
Studio mutations automatically queue an export to
`https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git`, branch
`Beta-3`. The jobs worker processes pending exports every minute. Streaming
imports stay active until their stream completes. Server actions for catalog
pages, offers, deletion and maintenance are covered as well.
## Server setup
1. Create a **dedicated clean clone** of the repository on `Beta-3`, outside the
CMS directory. Configure non-interactive Git push authentication for the
worker OS account using its credential helper. Do not put tokens in URLs.
2. Set `CATALOG_GIT_CHECKOUT` to that absolute clone directory in the CMS and
worker environments. Set `CATALOG_GIT_STATE_DIR` to a persistent, writable
directory outside the clone, shared by both processes on the same host.
3. Run `pnpm jobs:worker` alongside the CMS under your process supervisor.
Both processes must have access to the configured asset directories and DB.
The worker command enables the React server condition for server-only modules.
4. Restart the CMS after setting the environment. In **Studio → Sync**, use
**Export now / retry** for the initial export. Subsequent mutations queue
automatically. Status shows pending/active operations and the last commit.
Leaving `CATALOG_GIT_CHECKOUT` empty disables export. No credentials are shipped.
This source change alone does not configure or deploy the production service.
## Exported content
| Source | Repository destination |
| --- | --- |
| Configured Nitro bundles, including furniture, figures, effects and pets | `Gamedata/bundled` |
| Configured furni icons | `Gamedata/icons` |
| Existing badge/catalog images | `Gamedata/c_images` |
| FurnitureData and supported public game-data JSON files | `Gamedata/config` |
| Existing localized FurnitureData files | `catalogue version 2 ( Final (Dev)/langs furnidata` |
| `items_base`, `catalog_pages`, `catalog_items` | `catalogue version 2 ( Final (Dev)/sqls` |
| `catalog_pages_bc`, `catalog_items_bc`, when present | Same SQL directory |
SQL is read in one consistent, read-only InnoDB transaction. Dumps contain table
definitions and deterministic upserts with hexadecimal UTF-8 string literals.
Import `items_base.sql`, then `catalog_pages.sql`, then `catalog_items.sql`.
Existing schemas are not migrated by these dumps. Rows absent from the source
are omitted; importing an upsert dump into another existing database does not
delete that database's extra rows. No user, session or credential tables are exported.
Only existing local assets are exported. Translation generation follows Studio's
existing setting; export does not generate missing languages or download assets.
Public JSON is explicitly allowlisted so translation caches and private runtime
files cannot enter the repository. Invalid JSON or concurrent Studio changes
prevent publication of that snapshot.
## Failure and concurrency behavior
- Pending events survive process restarts; events added during publication remain
pending for the next run. Partial imports are exported as their settled local
state, including successful items from a batch containing failures.
- A single filesystem lock serializes the worker. Dead local process markers are
recovered on the next run. For an unreadable marker or a marker from another
host, stop the CMS and worker before repairing the queue directory.
- A failed push retains the local commit and pending events for retry. Concurrent
upstream commits are rebased; a conflict aborts the rebase and leaves the event
pending. Resolve conflicts in the dedicated clone, then retry.
- The checkout must be clean before each run. Unrelated files are preserved and
no force push is used. Missing local files do not cause remote deletions.
- Status errors omit raw Git output to avoid exposing authentication material.
## Verification
Run the `catalog-git-*` service tests and `src/lib/catalog-export-api.test.ts` with
Vitest. The integration test creates a temporary bare remote and verifies push,
failed-push recovery, no-op export and preservation of unrelated files. SQL
snapshot tests mock the database; production DB import and production push need
verification in the deployed environment.
+3
View File
@@ -71,6 +71,9 @@ RUN if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f ya
# --- Runtime stage ---
FROM node:26.8.1-bookworm-slim AS runner
# Catalog Studio publishes to self-hosted Git repositories over HTTPS.
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
# The CMS writes to bind-mounted host directories (/var/www/Gamedata is owned by
# the host's www-data user, UID/GID 33). The node base image already ships a
# www-data user with UID/GID 33, which matches that ownership — so we run as
+1 -1
View File
@@ -16,7 +16,7 @@
"format": "biome format --write .",
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
"jobs:worker": "tsx scripts/jobs-worker.ts",
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
"test": "vitest run",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit",
+6
View File
@@ -11,6 +11,7 @@ import {
import { logger } from "../src/lib/logger";
import { redis } from "../src/lib/redis";
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
import { rcon } from "../src/lib/services/rcon";
function captureWorkerError(err: unknown, context: string): void {
@@ -261,6 +262,11 @@ async function publishScheduledArticles(): Promise<void> {
}
async function main() {
new Cron("* * * * *", () => {
runCatalogExport().catch((e) =>
captureWorkerError(e, "Catalog export failed"),
);
});
logger.info("Worker started", { module: "jobs" });
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
+20 -1
View File
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -52,6 +53,7 @@ export async function updateBcPage({
...fields
}: { id: number } & Record<string, unknown>) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const data = pickAllowed(fields, BC_PAGE_FIELDS);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
@@ -70,10 +72,12 @@ export async function updateBcPage({
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
});
}
export async function deleteBcItem({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
await rcon.updateCatalog();
await logStaffActivity({
@@ -85,6 +89,7 @@ export async function deleteBcItem({ id }: { id: number }) {
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
});
}
export async function updateBcItem({
@@ -98,6 +103,7 @@ export async function updateBcItem({
extradata?: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
if (Object.keys(safe).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
@@ -116,6 +122,7 @@ export async function updateBcItem({
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
});
}
export async function createBcItem({
@@ -129,7 +136,10 @@ export async function createBcItem({
extradata: string;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
return await withCatalogExport(async () => {
const [result] = await db
.insert(CatalogItemsBc)
.values({ pageId, ...data });
const createdId = Number(result.insertId);
await rcon.updateCatalog();
await logStaffActivity({
@@ -141,6 +151,7 @@ export async function createBcItem({
});
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
});
}
export async function toggleBcPage({
@@ -151,6 +162,7 @@ export async function toggleBcPage({
field: "enabled" | "visible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [page] = await db
.select({
enabled: CatalogPagesBc.enabled,
@@ -167,6 +179,7 @@ export async function toggleBcPage({
await rcon.updateCatalog();
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const };
});
}
export async function createBcPage(input: {
@@ -180,6 +193,7 @@ export async function createBcPage(input: {
orderNum?: number;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [result] = await db.insert(CatalogPagesBc).values({
caption: input.caption,
parentId: input.parentId,
@@ -204,6 +218,7 @@ export async function createBcPage(input: {
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: { id: createdId } };
});
}
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
@@ -234,6 +249,7 @@ export async function reorderBcTreePage(input: {
newOrderNum: number;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
if (input.newParentId !== undefined) {
try {
await moveBcPage(input.pageId, input.newParentId);
@@ -252,6 +268,7 @@ export async function reorderBcTreePage(input: {
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
});
}
export async function deleteBcTreePage(input: {
@@ -259,6 +276,7 @@ export async function deleteBcTreePage(input: {
mode: "reparent" | "cascade";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [page] = await db
.select({ parentId: CatalogPagesBc.parentId })
.from(CatalogPagesBc)
@@ -307,4 +325,5 @@ export async function deleteBcTreePage(input: {
revalidatePath("/admin/catalog");
revalidatePath("/admin/catalog/builder-club");
return { ok: true as const, data: {} };
});
}
+17 -1
View File
@@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
import { CatalogItems, db, ItemsBase } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -123,6 +124,7 @@ export async function createCatalogItem(data: {
clubOnly: "0" | "1";
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
let catalogName = data.catalogName.trim();
if (!catalogName) {
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
@@ -149,6 +151,7 @@ export async function createCatalogItem(data: {
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id } };
});
}
/** Bulk create with one RCON refresh at the end. */
@@ -165,6 +168,7 @@ export async function bulkCreateCatalogItems({
}>;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
if (rows.length === 0) {
return { ok: true as const, data: { created: 0, failed: 0 } };
}
@@ -229,10 +233,12 @@ export async function bulkCreateCatalogItems({
}
return { ok: true as const, data: { created, failed } };
});
}
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
await rcon.updateCatalog();
await logStaffActivity({
@@ -243,6 +249,7 @@ export async function deleteCatalogItems({ ids }: { ids: number[] }) {
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function moveCatalogItems({
@@ -253,6 +260,7 @@ export async function moveCatalogItems({
targetPageId: number;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
if (ids.length === 0) {
return { ok: true as const, data: {} };
}
@@ -268,6 +276,7 @@ export async function moveCatalogItems({
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function reorderCatalogItems({
@@ -276,6 +285,7 @@ export async function reorderCatalogItems({
orders: Array<{ id: number; orderNumber: number }>;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
for (const { id, orderNumber } of orders) {
await db
.update(CatalogItems)
@@ -285,6 +295,7 @@ export async function reorderCatalogItems({
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function updateCatalogItem({
@@ -297,6 +308,7 @@ export async function updateCatalogItem({
baseItem?: { id: number; fields: Record<string, unknown> };
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
return { ok: false as const, error: "No valid fields to update" };
@@ -337,6 +349,7 @@ export async function updateCatalogItem({
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function translateCatalogItems(input: {
@@ -344,6 +357,7 @@ export async function translateCatalogItems(input: {
items: Array<{ id: number; publicName: string; description?: string }>;
}) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const parsed = translateItemsSchema.safeParse(input);
if (!parsed.success) {
return {
@@ -384,7 +398,8 @@ export async function translateCatalogItems(input: {
const nextName = item.publicName?.trim() ?? "";
const nextDesc = item.description ?? "";
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
const nameChanged =
nextName !== "" && nextName !== (base.publicName ?? "");
if (nameChanged) {
await db
@@ -461,4 +476,5 @@ export async function translateCatalogItems(input: {
updated: items.length,
},
};
});
}
+13
View File
@@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
import { CatalogPages, db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { deletePage, movePage } from "@/lib/services/catalog-tree";
import { rcon } from "@/lib/services/rcon";
import { logStaffActivity } from "@/lib/services/staff-activity";
@@ -48,6 +49,7 @@ export async function updateCatalogPage({
...fields
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const data = pickPageFields(fields);
if (Object.keys(data).length === 0) {
return { ok: false as const, error: "No valid fields to update" };
@@ -69,10 +71,12 @@ export async function updateCatalogPage({
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function deleteCatalogPage({ id }: { id: number }) {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await deletePage(id, "reparent");
await rcon.updateCatalog();
await logStaffActivity({
@@ -84,6 +88,7 @@ export async function deleteCatalogPage({ id }: { id: number }) {
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function toggleCatalogPage({
@@ -94,6 +99,7 @@ export async function toggleCatalogPage({
action: "toggleEnabled" | "toggleVisible";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [page] = await db
.select({
enabled: CatalogPages.enabled,
@@ -112,6 +118,7 @@ export async function toggleCatalogPage({
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function createCatalogPage(input: {
@@ -126,6 +133,7 @@ export async function createCatalogPage(input: {
orderNum?: number;
}): Promise<ActionResult<{ id: number }>> {
const staff = await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
const [result] = await db.insert(CatalogPages).values({
caption: input.caption,
parentId: input.parentId,
@@ -154,6 +162,7 @@ export async function createCatalogPage(input: {
});
revalidatePath("/admin/catalog");
return { ok: true as const, data: { id: createdId } };
});
}
export async function reorderTreePage(input: {
@@ -162,6 +171,7 @@ export async function reorderTreePage(input: {
newOrderNum: number;
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
if (input.newParentId !== undefined) {
try {
await movePage(input.pageId, input.newParentId);
@@ -179,6 +189,7 @@ export async function reorderTreePage(input: {
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
export async function deleteTreePage(input: {
@@ -186,8 +197,10 @@ export async function deleteTreePage(input: {
mode: "reparent" | "cascade";
}) {
await requirePermission(PERMS.CATALOG_EDIT);
return await withCatalogExport(async () => {
await deletePage(input.pageId, input.mode);
await rcon.updateCatalog();
revalidatePath("/admin/catalog");
return { ok: true as const, data: {} };
});
}
+13
View File
@@ -7,6 +7,7 @@ import {
actionOk,
handleActionError,
} from "@/lib/safe-action-shared";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import type {
AlignResult,
DedupResult,
@@ -38,7 +39,9 @@ export async function fixSpriteIdsAction(): Promise<
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.fixSpriteIds());
});
} catch (e) {
return handleActionError(e);
}
@@ -49,7 +52,9 @@ export async function fixCatalogOffersAction(): Promise<
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.fixCatalogOffers());
});
} catch (e) {
return handleActionError(e);
}
@@ -60,7 +65,9 @@ export async function reconcileIdsAction(): Promise<
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.reconcileIds());
});
} catch (e) {
return handleActionError(e);
}
@@ -71,7 +78,9 @@ export async function removeDuplicateItemsBaseAction(): Promise<
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.removeDuplicates());
});
} catch (e) {
return handleActionError(e);
}
@@ -93,7 +102,9 @@ export async function applyAlignIdsAction(): Promise<
> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
});
} catch (e) {
return handleActionError(e);
}
@@ -104,7 +115,9 @@ export async function fixEverythingAction(input?: {
}): Promise<ActionResult<maintenance.FixAllResult>> {
try {
await guard();
return await withCatalogExport(async () => {
return actionOk(await maintenance.fixEverything(input ?? {}));
});
} catch (e) {
return handleActionError(e);
}
+4 -4
View File
@@ -5,6 +5,7 @@ import { db, WebsiteSetting } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared";
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
import { deleteImportedItem } from "@/lib/services/furni-import";
import { siteSettings } from "@/lib/services/site-settings";
@@ -14,10 +15,9 @@ export const deleteImportedFurni = adminAction(
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
async (ctx) =>
actionOk(
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
string,
unknown
>,
(await withCatalogExport(() =>
deleteImportedItem(ctx.data.classname),
)) as unknown as Record<string, unknown>,
),
);
@@ -1,4 +1,5 @@
"use client";
import "./nitro-editor.css";
import {
Box,
@@ -240,6 +241,7 @@ export function NitroEditorDialog({
setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
setFlags({ ...originalFlags });
setJsonText(JSON.stringify(originalMetadata, null, 2));
setJsonError(null);
toast.info("Reset to original values");
};
@@ -334,7 +336,9 @@ export function NitroEditorDialog({
const hasChanges =
metadata &&
originalMetadata &&
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) ||
((activeTab === "json"
? jsonText !== JSON.stringify(originalMetadata, null, 2)
: JSON.stringify(metadata) !== JSON.stringify(originalMetadata)) ||
JSON.stringify(flags) !== JSON.stringify(originalFlags));
// Furni preview URL
@@ -344,13 +348,16 @@ export function NitroEditorDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0">
<DialogContent
data-nitro-editor
className="sm:max-w-4xl h-[min(840px,92dvh)] max-h-[92dvh] flex flex-col overflow-hidden p-0 gap-0"
>
{/* Header with preview */}
<DialogHeader className="px-6 pt-5 pb-4 border-b bg-muted/30">
<div className="flex items-start gap-4">
{/* Furni preview */}
<div
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
className="w-16 h-16 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
style={{
backgroundImage: "url(/images/furni_floor.png)",
backgroundRepeat: "repeat",
@@ -433,10 +440,11 @@ export function NitroEditorDialog({
{/* ── Visual Tab ── */}
<TabsContent
value="visual"
className="overflow-y-auto flex-1 px-6 py-4 space-y-5"
className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4 space-y-4"
>
{/* Dimensions + Interaction in 2-column layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="grid grid-cols-1 md:grid-cols-2 items-start gap-4">
<div className="space-y-4">
{/* Dimensions card */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
@@ -496,6 +504,36 @@ export function NitroEditorDialog({
</div>
</section>
{/* Available rotations */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Compass className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Directions</h3>
<span className="ml-auto text-xs text-muted-foreground">
{directions.length} / 8 active
</span>
</div>
<div className="grid grid-cols-4 gap-2">
{ALL_DIRECTIONS.map((dir) => (
<button
key={dir}
type="button"
className="nitro-direction"
aria-label={`Direction ${dir} (${DIR_LABELS[dir]})`}
aria-pressed={directions.includes(dir)}
onClick={() => toggleDirection(dir)}
>
<strong>{DIR_LABELS[dir]}</strong>
<span>{dir}</span>
</button>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Choose the available rotations. Keep at least one
direction active.
</p>
</section>
</div>
{/* Interaction card */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
@@ -506,6 +544,7 @@ export function NitroEditorDialog({
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Stand On</span>
<Switch
aria-label="Can Stand On"
checked={flags.canstandon}
onCheckedChange={(v) =>
setFlags((f) => ({ ...f, canstandon: !!v }))
@@ -515,6 +554,7 @@ export function NitroEditorDialog({
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Sit On</span>
<Switch
aria-label="Can Sit On"
checked={flags.cansiton}
onCheckedChange={(v) =>
setFlags((f) => ({ ...f, cansiton: !!v }))
@@ -524,6 +564,7 @@ export function NitroEditorDialog({
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
<span className="text-sm">Can Lay On</span>
<Switch
aria-label="Can Lay On"
checked={flags.canlayon}
onCheckedChange={(v) =>
setFlags((f) => ({ ...f, canlayon: !!v }))
@@ -581,58 +622,6 @@ export function NitroEditorDialog({
</section>
</div>
{/* Directions - visual compass */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
<Compass className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Directions</h3>
<span className="text-xs text-muted-foreground ml-auto">
{directions.length} active
</span>
</div>
<div className="flex items-center justify-center">
<div className="relative w-48 h-48">
{/* Compass circle */}
<div className="absolute inset-0 rounded-full border-2 border-muted" />
<div className="absolute inset-4 rounded-full border border-muted/50" />
{/* Direction buttons arranged in circle */}
{ALL_DIRECTIONS.map((dir) => {
const angle = (dir * 45 - 90) * (Math.PI / 180);
const r = 80;
const x = 96 + r * Math.cos(angle) - 18;
const y = 96 + r * Math.sin(angle) - 18;
const isActive = directions.includes(dir);
return (
<button
type="button"
key={dir}
className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
isActive
? "bg-primary text-primary-foreground border-primary shadow-md scale-110"
: "bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105"
}`}
style={{ left: x, top: y }}
onClick={() => toggleDirection(dir)}
>
<span className="text-[9px] leading-none">
{DIR_LABELS[dir]}
</span>
<span className="text-[8px] leading-none opacity-60">
{dir}
</span>
</button>
);
})}
{/* Center label */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-muted-foreground font-medium">
{directions.length}/8
</span>
</div>
</div>
</div>
</section>
{/* Types */}
<section className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2 mb-3">
@@ -858,7 +847,8 @@ export function NitroEditorDialog({
>
<div className="relative flex-1 min-h-0">
<Textarea
className="h-full min-h-[400px] font-mono text-xs resize-none"
aria-label="Nitro metadata JSON"
className="h-full min-h-0 font-mono text-xs resize-none"
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
spellCheck={false}
@@ -903,7 +893,13 @@ export function NitroEditorDialog({
</Button>
<Button
onClick={handleSave}
disabled={saving || loading || !metadata}
disabled={
saving ||
loading ||
!metadata ||
!hasChanges ||
(activeTab === "json" && !!jsonError)
}
className="gap-1.5"
>
{saving ? (
@@ -0,0 +1,63 @@
[data-nitro-editor] {
color: var(--admin-text);
background: var(--admin-surface);
border-color: var(--admin-border);
}
[data-nitro-editor] section,
[data-nitro-editor] .border,
[data-nitro-editor] .border-b,
[data-nitro-editor] .border-t {
border-color: var(--admin-border);
}
[data-nitro-editor] section {
background: var(--admin-canvas);
}
[data-nitro-editor] h3 {
font-size: 14px;
margin: 0;
}
[data-nitro-editor] [data-slot="switch"] {
width: 40px;
height: 24px;
background: var(--admin-border);
border: 1px solid var(--admin-text-muted);
}
[data-nitro-editor] [data-slot="switch"][data-checked] {
background: var(--admin-accent);
border-color: var(--admin-accent);
}
[data-nitro-editor] [data-slot="switch-thumb"] {
width: 18px;
height: 18px;
background: var(--admin-text);
color: var(--admin-canvas);
left: 2px;
}
[data-nitro-editor] [data-slot="switch-thumb"][data-checked] {
left: calc(100% - 20px);
}
[data-nitro-editor] .nitro-direction {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-height: 52px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface);
color: var(--admin-text-muted);
font-size: 12px;
}
[data-nitro-editor] .nitro-direction[aria-pressed="true"] {
border-color: var(--admin-accent);
background: var(--admin-accent);
color: var(--admin-accent-foreground);
}
[data-nitro-editor] .nitro-direction:focus-visible {
outline: 2px solid var(--admin-accent);
outline-offset: 3px;
}
[data-nitro-editor] [data-slot="dialog-footer"] {
flex-shrink: 0;
}
+1 -1
View File
@@ -49,7 +49,7 @@ export default async function AdminLayout({
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
<div
data-admin
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-screen"
className="flex flex-col min-w-0 p-5 lg:p-6 min-h-full"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<AdminTopbar staff={staff} />
+103 -22
View File
@@ -3,6 +3,7 @@
import {
BadgeCheck,
Cat,
ChevronDown,
ClipboardCheck,
Copy,
Dumbbell,
@@ -15,30 +16,93 @@ import {
Wrench,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useState } from "react";
import Link from "@/components/link";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
const TABS: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/admin/studio/furni", label: "Furni", icon: Puzzle },
{ href: "/admin/studio/upload", label: "Upload", icon: Upload },
type StudioLink = { href: string; label: string; icon: LucideIcon };
const assets: StudioLink[] = [
{ href: "/admin/studio/furni", label: "Furniture", icon: Puzzle },
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
{ href: "/admin/studio/pets", label: "Pets", icon: Cat },
{ href: "/admin/studio/clone", label: "Clone", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync", icon: RefreshCw },
];
const imports: StudioLink[] = [
{ href: "/admin/studio/upload", label: "Upload files", icon: Upload },
{ href: "/admin/studio/clone", label: "Import from a source", icon: Copy },
{ href: "/admin/studio/sync", label: "Sync & repository", icon: RefreshCw },
];
const maintenance: StudioLink[] = [
{ href: "/admin/studio/audit", label: "Catalog audit", icon: ClipboardCheck },
{
href: "/admin/studio/repair-icons",
label: "Repair Icons",
label: "Repair icons",
icon: ImageIcon,
},
{ href: "/admin/studio/audit", label: "Audit", icon: ClipboardCheck },
{
href: "/admin/studio/maintenance",
label: "Catalogus onderhoud",
label: "Catalog maintenance",
icon: Wrench,
},
];
const activePath = (path: string, href: string) =>
path === href || path.startsWith(`${href}/`);
function StudioMenu({
label,
links,
pathname,
}: {
label: string;
links: StudioLink[];
pathname: string;
}) {
const [open, setOpen] = useState(false);
const active = links.find((link) => activePath(pathname, link.href));
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium",
active
? "bg-[var(--admin-accent)]/10 text-[var(--admin-text)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
{label}
<ChevronDown size={14} />
</PopoverTrigger>
<PopoverContent
align="end"
className="w-64 border-[var(--admin-border)] bg-[var(--admin-surface)] p-1.5 text-[var(--admin-text)]"
>
{links.map(({ href, label: title, icon: Icon }) => (
<Link
key={href}
href={href}
style={{
color: activePath(pathname, href)
? "var(--admin-text)"
: "var(--admin-text-muted)",
}}
onClick={() => setOpen(false)}
aria-current={activePath(pathname, href) ? "page" : undefined}
className="flex min-h-11 items-center gap-3 rounded-md px-3 text-sm hover:bg-[var(--admin-accent)]/10 aria-[current=page]:bg-[var(--admin-accent)]/15"
>
<Icon size={16} />
{title}
</Link>
))}
</PopoverContent>
</Popover>
);
}
export default function StudioLayout({
children,
@@ -47,31 +111,48 @@ export default function StudioLayout({
}) {
const pathname = usePathname() ?? "";
return (
<div
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{ backgroundColor: "var(--admin-canvas)" }}
<div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]">
<nav
aria-label="Catalog Studio"
className="flex shrink-0 flex-wrap items-center justify-between gap-1 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
>
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-2 py-1.5">
{TABS.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<div className="flex flex-wrap gap-1">
{assets.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
style={{
color: activePath(pathname, href)
? "var(--admin-accent-foreground)"
: "var(--admin-text-muted)",
}}
aria-current={activePath(pathname, href) ? "page" : undefined}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
active
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
activePath(pathname, href)
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<Icon size={13} />
<Icon size={16} />
{label}
</Link>
);
})}
))}
</div>
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
<div className="flex flex-wrap gap-1">
<StudioMenu
label="Import & sync"
links={imports}
pathname={pathname}
/>
<StudioMenu
label="Maintenance"
links={maintenance}
pathname={pathname}
/>
</div>
</nav>
<div className="min-h-0 min-w-0 flex-1 overflow-auto">{children}</div>
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { adminFetch } from "@/lib/admin-fetch";
import { CatalogGitConfiguration } from "./catalog-git-configuration";
type ExportStatus = {
remote?: string;
branch?: string;
enabled: boolean;
pending: number;
active: number;
running?: boolean;
commit?: string;
finishedAt?: string;
error?: string;
};
export function CatalogExportClient({
canConfigure = false,
}: {
canConfigure?: boolean;
}) {
const [status, setStatus] = useState<ExportStatus | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const response = await adminFetch("/api/admin/catalog-export", {
cache: "no-store",
});
if (!response.ok) throw new Error("Unable to load export status");
setStatus(await response.json());
setError("");
} catch {
setError("Unable to load export status");
}
}, []);
useEffect(() => {
void refresh();
const timer = setInterval(() => {
void refresh();
}, 15_000);
return () => clearInterval(timer);
}, [refresh]);
async function retry() {
setBusy(true);
try {
const response = await adminFetch("/api/admin/catalog-export", {
method: "POST",
});
if (!response.ok) throw new Error("Unable to queue export");
await refresh();
} catch {
setError("Unable to queue export");
} finally {
setBusy(false);
}
}
return (
<section className="space-y-3 rounded-lg border border-[var(--admin-border)] p-4">
<h2 className="text-lg font-semibold">Catalog repository export</h2>
<p className="text-sm text-muted-foreground">
Automatically publishes bundles, icons, gamedata, translations and
catalog SQL to your configured Gitea repository.
</p>
{canConfigure && (
<CatalogGitConfiguration onSaved={() => void refresh()} />
)}
{status?.remote && (
<p className="text-xs break-all">
{status.remote} · {status.branch}
</p>
)}
<div role="status" className="text-sm" aria-live="polite">
{!status
? "Loading…"
: !status.enabled
? "Export disabled. Configure the Gitea connection below."
: status.active
? `Waiting for ${status.active} operation(s) to finish.`
: status.running
? "Export in progress…"
: status.pending
? `${status.pending} update(s) queued for export.`
: "No pending updates."}
{status?.commit && (
<p>
Last published commit:{" "}
<a
className="underline"
href={`${status.remote?.replace(/\.git$/, "")}/commit/${status.commit}`}
target="_blank"
rel="noreferrer"
>
{status.commit.slice(0, 12)}
</a>
</p>
)}
{status?.finishedAt && <p>Last attempt: {status.finishedAt}</p>}
</div>
{(error || status?.error) && (
<p role="alert" className="text-sm">
{error || status?.error}
</p>
)}
<button
type="button"
disabled={!status?.enabled || busy}
onClick={retry}
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm disabled:opacity-50"
>
{busy ? "Queuing…" : "Export now / retry"}
</button>
</section>
);
}
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { adminFetch } from "@/lib/admin-fetch";
interface Configuration {
provider: string;
enabled: boolean;
remote: string;
branch: string;
username: string;
hasToken: boolean;
}
export function CatalogGitConfiguration({ onSaved }: { onSaved: () => void }) {
const [config, setConfig] = useState<Configuration | null>(null),
[token, setToken] = useState(""),
[busy, setBusy] = useState(false),
[error, setError] = useState(""),
[message, setMessage] = useState("");
useEffect(() => {
const abort = new AbortController();
void (async () => {
try {
const res = await adminFetch("/api/admin/catalog-export/config", {
signal: abort.signal,
cache: "no-store",
});
if (!res.ok)
throw new Error(
"Unable to read Git settings. Settings edit permission is required.",
);
const data = await res.json();
if (!abort.signal.aborted) setConfig(data);
} catch (error) {
if (!abort.signal.aborted) setError((error as Error).message);
}
})();
return () => abort.abort();
}, []);
async function submit(action: "save" | "test") {
if (!config || busy) return;
setBusy(true);
setError("");
setMessage("");
try {
const res = await adminFetch("/api/admin/catalog-export/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...config, token, action }),
});
const data = await res.json();
if (!res.ok)
throw new Error(data.error || "Git configuration request failed");
if (action === "save") {
setConfig(data);
setToken("");
setMessage("Gitea configuration saved.");
onSaved();
} else setMessage(data.message);
} catch (error) {
setError((error as Error).message);
} finally {
setBusy(false);
}
}
return (
<details
className="rounded-lg border border-[var(--admin-border)] p-3"
open
>
<summary className="cursor-pointer font-semibold">
Gitea connection settings
</summary>
<p className="my-3 text-sm text-[var(--admin-text-muted)]">
Connect your self-hosted Gitea repository over HTTPS. The CMS manages
its own checkout; no Git-specific .env settings are needed.
</p>
{config && (
<fieldset disabled={busy} className="space-y-3">
<p className="text-sm">
Provider: <strong>Gitea self-hosted</strong>
</p>
<label htmlFor="catalog-git-1" className="block text-sm">
Repository URL
<Input
id="catalog-git-1"
type="url"
value={config.remote}
onChange={(event) =>
setConfig({ ...config, remote: event.target.value })
}
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label htmlFor="catalog-git-2" className="block text-sm">
Branch
<Input
id="catalog-git-2"
value={config.branch}
onChange={(event) =>
setConfig({ ...config, branch: event.target.value })
}
/>
</label>
<label htmlFor="catalog-git-3" className="block text-sm">
Gitea username
<Input
id="catalog-git-3"
autoComplete="off"
value={config.username}
onChange={(event) =>
setConfig({ ...config, username: event.target.value })
}
/>
</label>
</div>
<label htmlFor="catalog-git-4" className="block text-sm">
Gitea access token
<Input
id="catalog-git-4"
type="password"
autoComplete="new-password"
value={token}
placeholder={
config.hasToken
? "Token saved — leave blank to keep it"
: "Enter a token with repository write access"
}
onChange={(event) => setToken(event.target.value)}
/>
</label>
<p className="text-xs text-[var(--admin-text-muted)]">
The token is stored encrypted and is never returned to the browser.
Changing repository or username requires entering the token again.
</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={config.enabled}
onChange={(event) =>
setConfig({ ...config, enabled: event.target.checked })
}
/>
Automatically export catalog changes
</label>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => void submit("test")}>
Test connection
</Button>
<Button onClick={() => void submit("save")}>
{busy ? "Working…" : "Save Gitea settings"}
</Button>
</div>
</fieldset>
)}
{error && (
<p role="alert" className="mt-3 text-sm text-[var(--admin-warning)]">
{error}
</p>
)}
{message && (
<p role="status" className="mt-3 text-sm">
{message}
</p>
)}
</details>
);
}
+9
View File
@@ -3,6 +3,8 @@ import { OfficialSyncClient } from "@/app/admin/import/official/official-sync-cl
import { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { CatalogExportClient } from "./catalog-export-client";
export default async function StudioSyncPage(_props: {
params: Promise<{ locale: string }>;
}) {
@@ -12,6 +14,13 @@ export default async function StudioSyncPage(_props: {
}
return (
<div className="space-y-8">
<CatalogExportClient
canConfigure={canAccess(
permissions,
PERMS.SETTINGS_EDIT,
session.user.rank,
)}
/>
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
<p className="text-muted-foreground mt-2">
Scan configured sources and automatically import any furniture that is
@@ -0,0 +1,89 @@
// @vitest-environment node
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ connection: vi.fn(), guard: vi.fn() }));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: unknown, handler: unknown) => {
mocks.guard(options);
return handler;
},
}));
vi.mock("@/lib/services/catalog-git-managed", () => ({
testCatalogConnection: mocks.connection,
}));
import { PERMS } from "@/lib/permission-slugs";
import { GET, POST } from "./route";
let root: string;
const input = {
action: "save",
enabled: true,
remote: "https://gitea.example/user/catalog",
branch: "main",
username: "user",
token: "test-private-token",
};
const request = (data = input) =>
new NextRequest("http://localhost/api/admin/catalog-export/config", {
method: "POST",
body: JSON.stringify(data),
});
describe("Gitea configuration endpoint", () => {
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "catalog-config-test-"));
vi.stubEnv("CATALOG_GIT_STATE_DIR", root);
vi.stubEnv("AUTH_SECRET", "test-secret");
mocks.connection.mockReset().mockResolvedValue(undefined);
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
});
it("requires settings permission and never returns the token", async () => {
expect(mocks.guard).toHaveBeenCalledWith({
permission: PERMS.SETTINGS_EDIT,
});
const response = await POST(request());
expect(response.status).toBe(200);
const data = await response.json();
expect(data.hasToken).toBe(true);
expect(data.token).toBeUndefined();
expect(data.encryptedToken).toBeUndefined();
expect(
await fs.readFile(path.join(root, "config.json"), "utf8"),
).not.toContain(input.token);
expect(
await (
await GET(
new NextRequest("http://localhost/api/admin/catalog-export/config"),
)
).text(),
).not.toContain(input.token);
});
it("does not save unverified enabled configurations", async () => {
mocks.connection.mockRejectedValue(new Error("Cannot connect"));
expect((await POST(request())).status).toBe(400);
expect(await fs.readdir(root)).toEqual([]);
});
it("rejects configuration changes during an export", async () => {
await fs.writeFile(path.join(root, "worker.lock"), "active");
expect((await POST(request())).status).toBe(409);
expect(mocks.connection).not.toHaveBeenCalled();
});
it("prevents moving queued changes to a different repository", async () => {
await POST(request());
await fs.writeFile(path.join(root, "change.pending"), "pending");
expect(
(
await POST(
request({ ...input, remote: "https://other.example/user/repo" }),
)
).status,
).toBe(409);
});
});
@@ -0,0 +1,91 @@
import { promises as fs } from "node:fs";
import { hostname } from "node:os";
import path from "node:path";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permission-slugs";
import {
catalogStateRoot,
publicCatalogConfig,
readManagedCatalogConfig,
saveCatalogConfig,
validateCatalogConfig,
} from "@/lib/services/catalog-git-config";
import { testCatalogConnection } from "@/lib/services/catalog-git-managed";
export const GET = withAdmin({ permission: PERMS.SETTINGS_EDIT }, async () =>
apiOk(publicCatalogConfig()),
);
export const POST = withAdmin(
{ permission: PERMS.SETTINGS_EDIT },
async (request) => {
const body = await request.json().catch(() => null);
if (!body || !["save", "test"].includes(body.action))
return apiError("Choose save or test", 400);
let config: ReturnType<typeof validateCatalogConfig>;
try {
config = validateCatalogConfig(body, readManagedCatalogConfig());
} catch (error) {
return apiError((error as Error).message, 400);
}
if (body.action === "test") {
try {
await testCatalogConnection(config);
return apiOk({
message:
"Gitea repository and branch are readable. Write access is checked when exporting.",
});
} catch (error) {
return apiError((error as Error).message, 400);
}
}
const root = catalogStateRoot();
await fs.mkdir(root, { recursive: true });
const lock = await fs
.open(path.join(root, "worker.lock"), "wx")
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "EEXIST") return null;
throw error;
});
if (!lock)
return apiError("An export is running. Retry after it finishes.", 409);
try {
await lock.writeFile(
JSON.stringify({ pid: process.pid, host: hostname() }),
);
const entries = await fs.readdir(root);
if (entries.some((name) => name.endsWith(".active")))
return apiError(
"Wait for active catalog operations to finish before changing Git configuration.",
409,
);
const previous = readManagedCatalogConfig();
if (
previous &&
(previous.remote !== config.remote ||
previous.branch !== config.branch) &&
entries.some((name) => name.endsWith(".pending"))
)
return apiError(
"Export pending updates before changing repository or branch.",
409,
);
if (config.enabled) {
try {
await testCatalogConnection(config);
} catch (error) {
return apiError((error as Error).message, 400);
}
}
await saveCatalogConfig(config);
if (
previous &&
(previous.remote !== config.remote || previous.branch !== config.branch)
)
await fs.rm(path.join(root, "status.json"), { force: true });
return apiOk(publicCatalogConfig(config));
} finally {
await lock.close();
await fs.rm(path.join(root, "worker.lock"), { force: true });
}
},
);
+31
View File
@@ -0,0 +1,31 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { publicCatalogConfig } from "@/lib/services/catalog-git-config";
import {
catalogExportEnabled,
catalogExportQueue,
catalogExportStatus,
scheduleCatalogExport,
} from "@/lib/services/catalog-git-queue";
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
const status = await catalogExportStatus();
if (
status.enabled &&
status.pending > 0 &&
status.active === 0 &&
!("running" in status && status.running) &&
!("error" in status && status.error)
)
await scheduleCatalogExport();
const config = publicCatalogConfig();
return apiOk({ ...status, remote: config.remote, branch: config.branch });
});
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
if (!catalogExportEnabled())
return apiError("Catalog repository export is not configured", 400);
await catalogExportQueue().request();
await scheduleCatalogExport();
return apiOk(await catalogExportStatus());
});
@@ -125,6 +125,7 @@ export const POST = withAdmin(
revision: item.revision ?? 0,
category: item.category ?? "unknown",
skipFurniDataWrite: false,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl,
iconBaseUrl: source?.iconBaseUrl,
+92 -24
View File
@@ -4,6 +4,11 @@ import { eq, sql } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db, ItemsBase } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import {
indexLocalFurniture,
localFurnitureStatus,
} from "@/lib/furni/local-presence";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { getCloneInventoryStatus } from "@/lib/services/clone-inventory";
@@ -34,6 +39,7 @@ import {
} from "@/lib/services/furni-import";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
import { listFurniture } from "@/lib/services/habbofurni";
import { streamImportResponse } from "@/lib/services/import/core/single-response";
import { rcon } from "@/lib/services/rcon";
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
@@ -298,6 +304,11 @@ export const GET = withAdmin(
const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
// ── Browse a custom clone source (retro hotel furnidata) ────────
const localRows = await db
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
.from(ItemsBase);
const localIndex = indexLocalFurniture(localRows);
const importedSet = new Set(localIndex.keys());
const sourceId = request.nextUrl.searchParams.get("source");
if (sourceId) {
const source = await getSource(sourceId);
@@ -313,8 +324,45 @@ export const GET = withAdmin(
const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, "");
// Bulk-load FurnitureData and catalog references for status checks
const furniDataSet = new Set<string>();
const catalogIdSet = new Set<number>();
try {
const fd = (await readFurniData()) as {
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
};
for (const e of [
...(fd.roomitemtypes?.furnitype ?? []),
...(fd.wallitemtypes?.furnitype ?? []),
]) {
if (e.classname) furniDataSet.add(normalizeClassname(e.classname));
}
} catch {
/* best effort */
}
try {
const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`);
for (const row of (
rows as unknown as [Array<{ item_ids: string | null }>, unknown]
)[0]) {
for (const part of String(row.item_ids ?? "").split(/[;,]/)) {
const n = Number(part.trim());
if (Number.isFinite(n) && n > 0) catalogIdSet.add(n);
}
}
} catch {
/* best effort */
}
const enriched = items.map((item) => {
const imported = item.present;
const presence = localFurnitureStatus(
item.classname,
localIndex,
furniDataSet,
catalogIdSet,
);
const imported = presence.alreadyImported;
let nitroExists = false;
if (imported) {
const starIdx = item.classname.indexOf("*");
@@ -326,7 +374,17 @@ export const GET = withAdmin(
/*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`,
);
nitroExists = existsSync(/*turbopackIgnore: true*/ p);
nitroExists =
existsSync(/*turbopackIgnore: true*/ p) ||
(localIndex.get(normalizeClassname(item.classname)) ?? []).some(
(row) =>
existsSync(
path.join(
/*turbopackIgnore: true*/ nitroDir,
`${row.itemName.split("*")[0]}.nitro`,
),
),
);
}
return {
id: item.id,
@@ -334,12 +392,10 @@ export const GET = withAdmin(
type: item.itemType === "i" ? "wallitem" : "flooritem",
name: item.name,
description: item.description,
revision: 0,
revision: Number(item.revision) || 0,
category: String(item.category ?? ""),
alreadyImported: imported,
nitroExists: item.nitroExists ?? nitroExists,
hasFurnidata: item.hasFurnidata,
hasCatalogEntry: item.hasCatalogEntry,
...presence,
nitroExists,
iconUrl: sourceIconBase
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
: undefined,
@@ -358,16 +414,6 @@ export const GET = withAdmin(
});
}
// Pull every imported classname once so we can both (a) sort non-imported
// items to the top *before* pagination and (b) skip the per-page lookup
// below. Single-column indexed query — fast even at 10k+ rows.
const allImported = await db
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase);
const importedSet = new Set(
allImported.map((e) => e.itemName).filter((n): n is string => !!n),
);
// Bulk-load FurnitureData and catalog references for status checks
const furniDataSet = new Set<string>();
const catalogIdSet = new Set<number>();
@@ -380,7 +426,7 @@ export const GET = withAdmin(
...(fd.roomitemtypes?.furnitype ?? []),
...(fd.wallitemtypes?.furnitype ?? []),
]) {
if (e.classname) furniDataSet.add(e.classname);
if (e.classname) furniDataSet.add(normalizeClassname(e.classname));
}
} catch {
/* best effort */
@@ -407,7 +453,13 @@ export const GET = withAdmin(
});
const enriched = result.items.map((item) => {
const imported = importedSet.has(item.classname);
const presence = localFurnitureStatus(
item.classname,
localIndex,
furniDataSet,
catalogIdSet,
);
const imported = presence.alreadyImported;
let nitroExists = false;
if (imported) {
const starIdx = item.classname.indexOf("*");
@@ -419,14 +471,22 @@ export const GET = withAdmin(
/*turbopackIgnore: true*/ nitroDir,
`${base}.nitro`,
);
nitroExists = existsSync(/*turbopackIgnore: true*/ p);
nitroExists =
existsSync(/*turbopackIgnore: true*/ p) ||
(localIndex.get(normalizeClassname(item.classname)) ?? []).some(
(row) =>
existsSync(
path.join(
/*turbopackIgnore: true*/ nitroDir,
`${row.itemName.split("*")[0]}.nitro`,
),
),
);
}
return {
...item,
alreadyImported: imported,
...presence,
nitroExists,
hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
};
});
@@ -476,6 +536,7 @@ export const POST = withAdmin(
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
skipFurniDataWrite: false,
repairExisting: body.repairExisting === true,
});
if (result.ok) {
@@ -621,6 +682,7 @@ export const POST = withAdmin(
});
}
const runSingle = async () => {
// ── Single import ────────────────────────────────────────────────
const {
id,
@@ -634,7 +696,8 @@ export const POST = withAdmin(
updateExisting,
} = body;
if (!classname || !name) return apiError("Missing classname or name", 400);
if (!classname || !name)
return apiError("Missing classname or name", 400);
let source: Awaited<ReturnType<typeof getSource>> | null = null;
if (sourceId) source = await getSource(sourceId);
@@ -648,6 +711,7 @@ export const POST = withAdmin(
revision: revision ?? 0,
category: category ?? "unknown",
updateExisting: updateExisting === true,
repairExisting: body.repairExisting === true,
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
nitroBaseUrl: source?.nitroBaseUrl || undefined,
iconBaseUrl: source?.iconBaseUrl || undefined,
@@ -758,6 +822,10 @@ export const POST = withAdmin(
spriteCount: result.spriteCount,
warnings: result.warnings,
});
};
return request.headers.get("accept")?.includes("text/event-stream")
? streamImportResponse(runSingle)
: runSingle();
},
);
@@ -0,0 +1,143 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
read: vi.fn(),
stat: vi.fn(),
guard: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: unknown, handler: unknown) => {
mocks.guard(options);
return handler;
},
}));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
vi.mock("@/lib/services/furni-data", () => ({ readFurniData: mocks.read }));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
getFurniAssetDirs: async () => ({
nitroDir: "/assets/nitro",
iconDir: "/assets/icons",
}),
}));
vi.mock("node:fs", () => ({ promises: { stat: mocks.stat } }));
import { POST } from "./route";
const request = (classnames: unknown) =>
new NextRequest("http://localhost/api/admin/studio/inspect", {
method: "POST",
body: JSON.stringify({ classnames }),
});
describe("furniture inspection", () => {
beforeEach(() => {
mocks.execute.mockReset().mockResolvedValue([[], []]);
mocks.read.mockReset().mockResolvedValue({
roomitemtypes: { furnitype: [] },
wallitemtypes: { furnitype: [] },
});
mocks.stat.mockReset().mockRejectedValue({ code: "ENOENT" });
});
it("requires asset import permission", () => {
expect(mocks.guard).toHaveBeenCalledWith({
permission: expect.any(String),
});
});
it("rejects path traversal before reading data", async () => {
expect((await POST(request(["../secret"]))).status).toBe(400);
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.stat).not.toHaveBeenCalled();
});
it("uses local SQL IDs and color-aware asset filenames", async () => {
mocks.execute
.mockResolvedValueOnce([
[
{
id: 90,
spriteId: 91,
classname: "chair*2",
name: "Chair",
type: "s",
},
],
[],
])
.mockResolvedValueOnce([
[{ classname: "chair*2", id: 5, pageId: 7, credits: 3, points: 0 }],
[],
]);
const response = await POST(request(["chair*2"]));
const body = await response.json();
expect(body.items[0].sql[0].id).toBe(90);
expect(body.items[0].catalog[0].pageId).toBe(7);
expect(
mocks.stat.mock.calls.map((call) =>
String(call[0]).replaceAll("\\", "/"),
),
).toEqual(["/assets/nitro/chair.nitro", "/assets/icons/chair_2_icon.png"]);
});
it("distinguishes unreadable data and denied assets from missing files", async () => {
mocks.read.mockRejectedValue(new Error("unreadable"));
mocks.stat.mockRejectedValue({ code: "EACCES" });
const body = await (await POST(request(["chair"]))).json();
expect(body.items[0].furnidataReadable).toBe(false);
expect(body.items[0].icon.exists).toBeNull();
expect(body.items[0].nitro.exists).toBeNull();
});
it("handles malformed furnidata without inventing a clean comparison", async () => {
mocks.read.mockResolvedValue({
roomitemtypes: { furnitype: {} },
wallitemtypes: { furnitype: [null] },
});
const response = await POST(request(["chair"]));
expect(response.status).toBe(200);
expect((await response.json()).items[0].furnidataReadable).toBe(false);
});
it("recognizes existing SQL and furnidata across classname casing", async () => {
mocks.execute
.mockResolvedValueOnce([
[
{
id: 2000037258,
spriteId: 2000037258,
classname: "recycler_kintsugib",
name: "Local",
type: "s",
},
],
[],
])
.mockResolvedValueOnce([
[
{
id: 3,
pageId: 4,
classname: "RECYCLER_KINTSUGIB",
credits: 3,
points: 0,
},
],
[],
]);
mocks.read.mockResolvedValue({
roomitemtypes: {
furnitype: [
{ id: 2000037258, classname: "recycler_kintsugib", name: "Local" },
],
},
wallitemtypes: { furnitype: [] },
});
mocks.stat.mockImplementation(async (filename) => {
if (String(filename).includes("kintsugib"))
return { size: 256, isFile: () => true };
throw { code: "ENOENT" };
});
const result = await (await POST(request(["recycler_kintsugiB"]))).json();
expect(result.items[0].sql[0].id).toBe(2000037258);
expect(result.items[0].catalog).toHaveLength(1);
expect(result.items[0].furnidata).toHaveLength(1);
expect(result.items[0].nitro.exists).toBe(true);
expect(result.items[0].icon.exists).toBe(true);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { db } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import {
type FurnitureInspection,
validateClassnames,
} from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { readFurniData } from "@/lib/services/furni-data";
async function asset(
directory: string,
name: string,
): Promise<{ exists: boolean | null; bytes: number }> {
try {
const stat = await fs.stat(path.join(directory, name));
return { exists: stat.isFile(), bytes: stat.size };
} catch (error) {
return {
exists: (error as NodeJS.ErrnoException).code === "ENOENT" ? false : null,
bytes: 0,
};
}
}
async function assetCandidates(directory: string, names: string[]) {
const results = await Promise.all(
[...new Set(names)].map((name) => asset(directory, name)),
);
return (
results.find((result) => result.exists === true) ??
results.find((result) => result.exists === null) ??
results[0]
);
}
// Read-only inspection lives outside import routes so it cannot enqueue a Git export.
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const body = await request.json().catch(() => null);
const names = validateClassnames(body?.classnames);
if (!names)
return apiError("Provide 1–500 valid furniture classnames", 400);
const namesSql = sql.join(
names.map((name) => sql`${normalizeClassname(name)}`),
sql`, `,
);
const [rows] = (await db.execute(
sql`SELECT id, sprite_id AS spriteId, item_name AS classname, public_name AS name, type FROM items_base WHERE LOWER(TRIM(item_name)) IN (${namesSql})`,
)) as unknown as [
Array<FurnitureInspection["sql"][number] & { classname: string }>,
unknown,
];
const [offers] = (await db.execute(
sql`SELECT ib.item_name AS classname, ci.id, ci.page_id AS pageId, ci.cost_credits AS credits, ci.cost_points AS points FROM items_base ib JOIN catalog_items ci ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE LOWER(TRIM(ib.item_name)) IN (${namesSql})`,
)) as unknown as [
Array<FurnitureInspection["catalog"][number] & { classname: string }>,
unknown,
];
let readable = true;
let data: Record<string, unknown> = {};
try {
data = await readFurniData();
} catch {
readable = false;
}
const entries = new Map<string, FurnitureInspection["furnidata"]>();
for (const [section, type] of [
["roomitemtypes", "flooritem"],
["wallitemtypes", "wallitem"],
] as const) {
const list = (
data[section] as
| { furnitype?: Array<Record<string, unknown>> }
| undefined
)?.furnitype;
if (!Array.isArray(list)) {
readable = false;
continue;
}
for (const entry of list) {
if (!entry || typeof entry !== "object") {
readable = false;
continue;
}
const name = String(entry.classname ?? "");
const key = normalizeClassname(name);
if (!names.some((value) => normalizeClassname(value) === key)) continue;
const values = entries.get(key) ?? [];
values.push({
id: Number(entry.id),
name: String(entry.name ?? ""),
description: String(entry.description ?? ""),
revision: Number(entry.revision ?? 0),
type,
});
entries.set(key, values);
}
}
const dirs = await getFurniAssetDirs();
const items: FurnitureInspection[] = [];
for (let offset = 0; offset < names.length; offset += 20) {
items.push(
...(await Promise.all(
names.slice(offset, offset + 20).map(async (classname) => {
const variants = [
classname,
...rows
.filter(
(row) =>
normalizeClassname(row.classname) ===
normalizeClassname(classname),
)
.map((row) => row.classname),
];
const [nitro, icon] = await Promise.all([
assetCandidates(
dirs.nitroDir,
variants.map((name) => `${name.split("*")[0]}.nitro`),
),
assetCandidates(
dirs.iconDir,
variants.map((name) => `${name.replaceAll("*", "_")}_icon.png`),
),
]);
return {
classname,
sql: rows.filter(
(row) =>
normalizeClassname(row.classname) ===
normalizeClassname(classname),
),
catalog: offers.filter(
(row) =>
normalizeClassname(row.classname) ===
normalizeClassname(classname),
),
furnidata: entries.get(normalizeClassname(classname)) ?? [],
furnidataReadable: readable,
nitro,
icon,
};
}),
)),
);
}
return apiOk({ items });
},
);
@@ -0,0 +1,84 @@
import { NextRequest } from "next/server";
import { beforeEach, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
const mocks = vi.hoisted(() => ({
guard: vi.fn(),
source: vi.fn(),
official: vi.fn(),
inspect: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: unknown, handler: unknown) => {
mocks.guard(options);
return handler;
},
}));
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
getOfficialHabboFurnidata: mocks.official,
}));
vi.mock("@/lib/services/furniture-source-assets", () => ({
inspectSourceAssets: mocks.inspect,
}));
import { POST } from "./route";
const item = {
id: 1,
classname: "chair",
name: "Chair",
description: "",
revision: 1,
type: "flooritem",
category: "other",
};
const request = (body: unknown) =>
new NextRequest("http://localhost/api/admin/studio/source-assets", {
method: "POST",
body: JSON.stringify(body),
});
beforeEach(() => {
mocks.source.mockReset().mockResolvedValue(null);
mocks.official.mockReset().mockResolvedValue(new Map());
mocks.inspect.mockReset().mockResolvedValue({
classname: "chair",
state: "missing",
revision: 1,
alternatives: [],
});
});
it("requires import permission", () =>
expect(mocks.guard).toHaveBeenCalledWith({
permission: PERMS.ASSETS_IMPORT,
}));
it.each([
[],
Array(21).fill(item),
[{ ...item, classname: "../secret" }],
[{ ...item, revision: -1 }],
[null],
])("rejects invalid items before network checks: %j", async (items) => {
expect((await POST(request({ items }))).status).toBe(400);
expect(mocks.inspect).not.toHaveBeenCalled();
});
it("rejects unknown configured sources", async () => {
expect(
(await POST(request({ items: [item], sourceId: "missing" }))).status,
).toBe(404);
expect(mocks.inspect).not.toHaveBeenCalled();
});
it("uses server source configuration instead of client URLs", async () => {
const source = { id: "custom", nitroBaseUrl: "https://configured.example" };
mocks.source.mockResolvedValue(source);
const response = await POST(
request({
items: [item],
sourceId: "custom",
nitroBaseUrl: "https://untrusted.example",
}),
);
expect(response.status).toBe(200);
expect(mocks.inspect).toHaveBeenCalledWith(item, [], source);
expect((await response.json()).items[0].state).toBe("missing");
});
@@ -0,0 +1,59 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import type {
SourceAssetCheck,
SourceAssetItem,
} from "@/lib/furni/source-assets";
import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { getSource } from "@/lib/services/clone-sources";
import { inspectSourceAssets } from "@/lib/services/furniture-source-assets";
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request) => {
const body = await request.json().catch(() => null);
if (
!Array.isArray(body?.items) ||
body.items.length > 20 ||
!validateClassnames(
body.items.map((item: SourceAssetItem) => item?.classname),
)
)
return apiError("Provide 1–20 furniture items", 400);
if (
body.items.some(
(item: SourceAssetItem) =>
!Number.isSafeInteger(item.revision) ||
item.revision < 0 ||
typeof item.name !== "string" ||
item.name.length > 500,
)
)
return apiError("Invalid source metadata", 400);
const source = body.sourceId ? await getSource(body.sourceId) : null;
if (body.sourceId && !source) return apiError("Source not found", 404);
const entries = await getOfficialHabboFurnidata();
const official: SourceAssetItem[] = [...entries.values()].map((entry) => ({
id: entry.id,
classname: entry.classname,
name: entry.name,
description: entry.description,
type: entry.itemType === "i" ? "wallitem" : "flooritem",
revision: entry.revision,
category: entry.category,
}));
const results: SourceAssetCheck[] = [];
for (let offset = 0; offset < body.items.length; offset += 4)
results.push(
...(await Promise.all(
body.items
.slice(offset, offset + 4)
.map((item: SourceAssetItem) =>
inspectSourceAssets(item, official, source),
),
)),
);
return apiOk({ items: results });
},
);
+10 -5
View File
@@ -42,11 +42,11 @@ export function AdminMobileWrapper({
return (
<AdminMobileContext.Provider value={mobile}>
{/* Wider than public max-w-7xl (80rem), capped so it never goes full-bleed. */}
<div className="min-h-screen bg-[var(--admin-canvas)]">
<div className="mx-auto flex min-h-screen w-full max-w-full min-w-0 flex-col lg:max-w-[96rem] lg:flex-row lg:border-x lg:shadow-xl border-[var(--admin-border)]">
<div className="h-dvh overflow-hidden bg-[var(--admin-canvas)]">
<div className="mx-auto flex h-full min-h-0 w-full max-w-full min-w-0 flex-col lg:max-w-[96rem] lg:flex-row lg:border-x lg:shadow-xl border-[var(--admin-border)]">
{/* Mobile header */}
<div
className={`lg:hidden sticky top-0 flex items-center gap-3 px-4 py-3 border-b ${
className={`lg:hidden shrink-0 flex items-center gap-3 px-4 py-3 border-b ${
open ? "z-30" : "z-50"
}`}
style={{
@@ -84,7 +84,7 @@ export function AdminMobileWrapper({
{/* Sidebar */}
<div
className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:!min-h-screen lg:!translate-x-0 ${
className={`fixed inset-y-0 left-0 z-50 flex h-full w-[260px] flex-col overflow-hidden lg:!relative lg:shrink-0 lg:!translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
style={{ transition: "transform 300ms ease-in-out" }}
@@ -93,7 +93,12 @@ export function AdminMobileWrapper({
</div>
{/* Content */}
<div className="flex flex-col flex-1 min-w-0">{children}</div>
<div
data-admin-content
className="min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain"
>
{children}
</div>
</div>
</div>
</AdminMobileContext.Provider>
+4 -3
View File
@@ -46,11 +46,12 @@ export function AdminNavLink({
<Link
href={href}
onClick={close}
style={{ color: "var(--admin-sidebar-text-readable)" }}
aria-current={active ? "page" : undefined}
className={`group flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm no-underline transition-colors duration-150
className={`group flex min-h-10 items-center gap-2.5 rounded-lg border-l-2 border-transparent px-3 py-2 text-sm no-underline transition-colors duration-150
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--admin-sidebar-background)] ${
active
? "bg-[var(--admin-accent)]/25 font-semibold text-[var(--admin-sidebar-text-readable)] border-l-2 border-[var(--admin-accent)] shadow-sm"
? "bg-[var(--admin-accent)]/25 font-semibold text-[var(--admin-sidebar-text-readable)] border-l-[var(--admin-accent)] shadow-sm"
: "font-medium text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/10 hover:no-underline"
}`}
>
@@ -65,7 +66,7 @@ export function AdminNavLink({
{icon}
</span>
) : null}
<span className="truncate">{children}</span>
<span className="min-w-0 flex-1 break-words leading-5">{children}</span>
</Link>
);
}
+98 -10
View File
@@ -1,6 +1,12 @@
"use client";
import { ChevronDown, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
import {
ChevronDown,
ChevronsDownUp,
ChevronsUpDown,
Search,
X,
} from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -75,6 +81,7 @@ export function AdminSidebarNav({
const pathname = usePathname() ?? "";
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [hydrated, setHydrated] = useState(false);
const [query, setQuery] = useState("");
const allowed = useMemo(
() => new Set(allowedPermissions),
@@ -94,12 +101,39 @@ export function AdminSidebarNav({
.filter((group) => group.items.length > 0);
}, [allowed, isSuperAdmin, navConfig]);
const search = query.trim().toLocaleLowerCase();
const visibleGroups = useMemo(
() =>
groups
.map((group) => ({
...group,
items: group.items.filter(
(item) =>
!search ||
t(group.labelKey).toLocaleLowerCase().includes(search) ||
t(item.labelKey).toLocaleLowerCase().includes(search),
),
}))
.filter((group) => group.items.length > 0),
[groups, search, t],
);
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
useEffect(() => {
setCollapsed(readCollapsed());
const saved = readCollapsed();
setCollapsed(
Object.fromEntries(
groups.map((group) => [
group.labelKey,
typeof saved[group.labelKey] === "boolean"
? saved[group.labelKey]
: true,
]),
),
);
setHydrated(true);
}, []);
}, [groups]);
// Keep the group that contains the current page open.
useEffect(() => {
@@ -163,11 +197,50 @@ export function AdminSidebarNav({
className="flex min-h-0 flex-1 flex-col overflow-hidden"
aria-label={t("overview")}
>
<div className="shrink-0 px-3 pt-3">
<div className="relative">
<Search
size={16}
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--admin-sidebar-muted-readable)]"
/>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape" && query) {
event.stopPropagation();
setQuery("");
}
}}
aria-label={t("searchMenu")}
placeholder={t("searchMenu")}
style={{
paddingLeft: 36,
paddingRight: 36,
backgroundColor: "var(--admin-sidebar-background)",
color: "var(--admin-sidebar-text-readable)",
}}
className="h-10 w-full min-w-0 rounded-lg border border-[var(--admin-border)] text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label={t("clearMenuSearch")}
className="absolute right-1 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-md text-[var(--admin-sidebar-text-readable)] focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
>
<X size={14} />
</button>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1 border-b border-[var(--admin-border)] px-3 py-2">
<button
type="button"
onClick={expandAll}
disabled={allExpanded}
disabled={allExpanded || !!search}
title={t("expandAll")}
aria-label={t("expandAll")}
className={cn(
@@ -186,7 +259,7 @@ export function AdminSidebarNav({
<button
type="button"
onClick={collapseAll}
disabled={!canCollapseMore}
disabled={!canCollapseMore || !!search}
title={t("collapseAll")}
aria-label={t("collapseAll")}
className={cn(
@@ -205,9 +278,17 @@ export function AdminSidebarNav({
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3">
{groups.map((group, gi) => {
{visibleGroups.length === 0 && (
<p
role="status"
className="px-2 py-6 text-sm text-[var(--admin-sidebar-muted-readable)]"
>
{t("noMenuResults")}
</p>
)}
{visibleGroups.map((group, gi) => {
const GroupIcon = group.icon;
const isCollapsed = Boolean(collapsed[group.labelKey]);
const isCollapsed = !search && Boolean(collapsed[group.labelKey]);
const panelId = `admin-nav-${group.labelKey}`;
return (
@@ -220,12 +301,13 @@ export function AdminSidebarNav({
<button
type="button"
onClick={() => toggle(group.labelKey)}
disabled={!!search}
aria-expanded={!isCollapsed}
aria-controls={panelId}
className={cn(
"flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left",
"text-[0.65rem] font-extrabold uppercase tracking-[0.14em]",
"text-[var(--admin-sidebar-text-readable)] opacity-70",
"flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left",
"text-xs font-semibold",
"text-[var(--admin-sidebar-muted-readable)]",
"hover:opacity-100 hover:bg-[var(--admin-accent)]/10",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
"transition-colors",
@@ -235,6 +317,12 @@ export function AdminSidebarNav({
<span className="min-w-0 flex-1 truncate">
{t(group.labelKey)}
</span>
<span
aria-hidden
className="text-xs tabular-nums text-[var(--admin-sidebar-muted-readable)]"
>
{group.items.length}
</span>
<ChevronDown
size={14}
className={cn(
+125 -192
View File
@@ -1,44 +1,19 @@
"use client";
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Ban, Check, Circle, Loader2, RefreshCw, X } from "lucide-react";
import { memo } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
function SummaryRow({
label,
value,
tone,
}: {
label: string;
value: string | number;
tone?: "ok" | "warn";
}) {
return (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-[var(--admin-text-muted)]">{label}</span>
<span
className={cn(
"font-mono font-semibold",
tone === "warn"
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]",
)}
>
{value}
</span>
</div>
);
}
function formatEta(ms: number): string {
if (ms < 60_000) return `${Math.ceil(ms / 1000)}s`;
const min = Math.floor(ms / 60_000);
const sec = Math.ceil((ms % 60_000) / 1000);
return `${min}m ${sec}s`;
}
const statusLabels: Record<BatchItemStatus["status"], string> = {
pending: "Waiting",
started: "Starting",
downloading: "Downloading assets",
converting: "Preparing assets",
writing_db: "Saving",
done: "Imported",
failed: "Failed",
};
export const BatchProgress = memo(function BatchProgress({
progress,
@@ -47,7 +22,6 @@ export const BatchProgress = memo(function BatchProgress({
failed,
cancelling,
verification,
startTime,
failedClassnames,
onCancel,
onDismiss,
@@ -65,174 +39,66 @@ export const BatchProgress = memo(function BatchProgress({
onDismiss?: () => void;
onRetry?: (classnames: string[]) => void;
}) {
const { entries, total, completed, percent } = useMemo(() => {
const e = [...progress.entries()];
const t = e.length;
const c = e.filter(
([, s]) => s.status === "done" || s.status === "failed",
const entries = [...progress.values()];
const completed = entries.filter(
(item) => item.status === "done" || item.status === "failed",
).length;
return {
entries: e,
total: t,
completed: c,
percent: t > 0 ? Math.round((c / t) * 100) : 0,
};
}, [progress]);
// Live ETA calculation
const [eta, setEta] = useState<string | null>(null);
const completedRef = useRef(completed);
const totalRef = useRef(total);
completedRef.current = completed;
totalRef.current = total;
useEffect(() => {
if (done || !startTime || completed === 0) {
setEta(null);
return;
}
const calc = () => {
const elapsed = Date.now() - startTime;
const avgPerItem = elapsed / completedRef.current;
const remaining = (totalRef.current - completedRef.current) * avgPerItem;
setEta(formatEta(remaining));
};
calc();
const interval = setInterval(calc, 2000);
return () => clearInterval(interval);
}, [done, startTime, completed]);
const total = entries.length;
const percent = total ? Math.round((completed / total) * 100) : 0;
const imported = done
? succeeded
: entries.filter((item) => item.status === "done").length;
const errors = done
? failed
: entries.filter((item) => item.status === "failed").length;
const unfinished = Math.max(0, total - completed);
const heading = !done
? cancelling
? "Stopping import…"
: "Importing furniture…"
: unfinished
? "Import stopped"
: errors
? "Import completed with errors"
: "Import completed";
const issues = verification
? verification.furniDataMissing + verification.furniDataConflicts
: 0;
return (
<div className="border-t border-[var(--admin-border)] p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
{done ? "Import finished" : "Importing…"}
</span>
{done ? (
<span
className={cn(
"text-xs font-bold",
failed > 0
? "text-[var(--admin-warning)]"
: "text-[var(--admin-success)]",
)}
<section
aria-label="Import progress"
className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
>
{completed}/{total} · {succeeded} ok
{failed > 0 ? ` · ${failed} failed` : ""}
</span>
) : (
<span className="flex items-center gap-2">
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
{completed}/{total}
</span>
{eta && (
<span className="text-xs font-mono text-[var(--admin-accent)]">
~{eta}
</span>
)}
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
/>
</span>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div role="status" aria-live="polite">
<h2
style={{ fontSize: 14, margin: 0 }}
className="text-sm font-semibold"
>
{heading}
</h2>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
{imported} imported · {errors} failed
{unfinished > 0 &&
` · ${unfinished} ${done ? "not completed" : "remaining"}`}
</p>
</div>
{!done && (
<div className="mb-2 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-xs font-mono font-bold text-[var(--admin-text)]">
{percent}%
</span>
</div>
)}
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
{entries.map(([classname, s]) => (
<div key={classname} className="flex items-center gap-2 text-xs">
{s.status === "done" ? (
<Check
size={12}
className="shrink-0 text-[var(--admin-success)]"
/>
) : s.status === "failed" ? (
<X size={12} className="shrink-0 text-[var(--admin-warning)]" />
) : (
<Loader2
size={12}
className="shrink-0 animate-spin text-[var(--admin-accent)]"
/>
)}
<span className="min-w-0 flex-1 truncate font-mono">
{classname}
</span>
<span className="shrink-0 capitalize text-[var(--admin-text-muted)]">
{s.status.replace(/_/g, " ")}
</span>
</div>
))}
</div>
{done && verification && (
<div className="mt-2 space-y-1.5 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-2.5">
<div className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<ShieldCheck size={13} className="text-[var(--admin-success)]" />
Post-import verification
</div>
<SummaryRow
label="Catalog offer_id fixed"
value={verification.offerIdsFixed}
/>
<SummaryRow
label="Furnidata ids fixed"
value={verification.furniDataFixedIds}
/>
<SummaryRow
label="Furnidata entries missing"
value={verification.furniDataMissing}
tone={verification.furniDataMissing > 0 ? "warn" : "ok"}
/>
<SummaryRow
label="Furnidata id conflicts"
value={verification.furniDataConflicts}
tone={verification.furniDataConflicts > 0 ? "warn" : "ok"}
/>
<SummaryRow
label="Nitros synced"
value={verification.nitrosSynced.length}
/>
<SummaryRow
label="Icons synced"
value={verification.iconsSynced.length}
/>
<SummaryRow
label="Folders chowned"
value={verification.ownershipFixed.length}
/>
</div>
)}
<div className="mt-2 flex justify-end gap-2">
<div className="flex flex-wrap gap-2">
{done ? (
<>
{failedClassnames && failedClassnames.length > 0 && onRetry && (
{!!failedClassnames?.length && onRetry && (
<Button
size="sm"
variant="outline"
onClick={() => onRetry(failedClassnames)}
>
<Loader2 size={14} />
Retry {failedClassnames.length} failed
<RefreshCw size={14} />
Retry failed ({failedClassnames.length})
</Button>
)}
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
Dismiss
Close summary
</Button>
</>
) : (
@@ -243,14 +109,81 @@ export const BatchProgress = memo(function BatchProgress({
disabled={cancelling}
>
{cancelling ? (
<Loader2 size={14} className="animate-spin" />
<Loader2 className="animate-spin" size={14} />
) : (
<Ban size={14} />
)}
Cancel
Stop import
</Button>
)}
</div>
</div>
<div
role="progressbar"
aria-label="Furniture processed"
aria-valuemin={0}
aria-valuemax={total || 1}
aria-valuenow={completed}
className="my-3 h-2 overflow-hidden rounded-full bg-[var(--admin-accent)]/10"
>
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all"
style={{ width: `${percent}%` }}
/>
</div>
{done && verification && (
<p className="mb-2 text-sm text-[var(--admin-text-muted)]">
{issues
? `${issues} catalog data issue(s) still need attention. Open Catalog audit to review them.`
: "Catalog data verification completed."}
</p>
)}
<details>
<summary className="cursor-pointer text-xs font-medium">
View item details ({total})
</summary>
<div className="mt-2 max-h-44 space-y-2 overflow-auto">
{entries.map((item) => (
<div
key={item.classname}
className="rounded-md bg-[var(--admin-canvas)] p-2 text-xs"
>
<div className="flex items-center gap-2">
{item.status === "done" ? (
<Check size={14} className="text-[var(--admin-success)]" />
) : item.status === "failed" ? (
<X size={14} className="text-[var(--admin-warning)]" />
) : done || item.status === "pending" ? (
<Circle size={12} />
) : (
<Loader2 size={14} className="animate-spin" />
)}
<span className="min-w-0 flex-1 break-all font-medium">
{item.classname}
</span>
<span className="shrink-0 text-[var(--admin-text-muted)]">
{done && item.status !== "done" && item.status !== "failed"
? "Not completed"
: statusLabels[item.status]}
</span>
</div>
{item.message && (
<p className="mt-1 break-words text-[var(--admin-text-muted)]">
{item.message}
</p>
)}
{[...new Set(item.warnings ?? [])].map((warning) => (
<p
key={warning}
className="mt-1 break-words text-[var(--admin-warning)]"
>
{warning}
</p>
))}
</div>
))}
</div>
</details>
</section>
);
});
+98 -70
View File
@@ -1,8 +1,7 @@
"use client";
import { ChevronDown, ChevronRight, FolderTree } from "lucide-react";
import { memo, useCallback, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, FolderTree, Search, X } from "lucide-react";
import { memo, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog";
@@ -10,93 +9,122 @@ export const CatalogRail = memo(function CatalogRail({
tree,
selectedId,
onSelect,
onClose,
}: {
tree: TreeNode[];
selectedId: number | null;
onSelect: (id: number | null) => void;
onClose?: () => void;
}) {
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const [search, setSearch] = useState("");
const visible = useMemo(() => {
if (search.trim())
return tree.filter((node) =>
node.caption.toLowerCase().includes(search.trim().toLowerCase()),
);
const hidden = new Set<number>();
for (const node of tree) {
if (hidden.has(node.parentId)) {
for (const node of tree)
if (hidden.has(node.parentId) || collapsed.has(node.parentId))
hidden.add(node.id);
continue;
}
if (collapsed.has(node.parentId)) {
hidden.add(node.id);
}
}
return tree.filter((n) => !hidden.has(n.id));
}, [tree, collapsed]);
const toggle = useCallback((id: number) => {
setCollapsed((prev) => {
const next = new Set(prev);
return tree.filter((node) => !hidden.has(node.id));
}, [tree, collapsed, search]);
function toggle(id: number) {
setCollapsed((previous) => {
const next = new Set(previous);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
}
return (
<aside className="flex h-full min-h-0 w-64 shrink-0 flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)]">
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] px-3 py-2.5">
<FolderTree size={14} className="text-[var(--admin-accent)]" />
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Catalog
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
{visible.length === 0 ? (
<p className="px-2 py-4 text-xs text-[var(--admin-text-muted)]">
No catalog pages found.
</p>
) : (
visible.map((node) => {
const isRoot = node.parentId <= 0;
const hasChildren = node.childCount > 0;
const isCollapsed = collapsed.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => {
if (hasChildren) toggle(node.id);
onSelect(selectedId === node.id ? null : node.id);
}}
className={cn(
"group flex w-full cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
selectedId === node.id
? "bg-[var(--admin-accent)]/15 text-[var(--admin-text)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]",
)}
style={{ paddingLeft: `${6 + node.depth * 12}px` }}
<aside
aria-label="Catalog categories"
className="absolute inset-y-0 left-0 z-30 flex w-64 max-w-[85%] flex-col border-r border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:z-auto lg:shrink-0 lg:shadow-none"
>
{hasChildren ? (
isCollapsed ? (
<ChevronRight size={12} className="shrink-0" />
) : (
<ChevronDown size={12} className="shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
<div className="flex items-center gap-2 px-3 pt-3">
<FolderTree size={16} />
<h2
style={{ fontSize: 14, margin: 0 }}
className="flex-1 text-sm font-semibold"
>
Categories
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close categories"
className="grid h-9 w-9 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
>
<X size={16} />
</button>
</div>
<label className="mx-3 my-3 flex items-center gap-2 rounded-md border border-[var(--admin-border)] px-2">
<Search size={14} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Find a category…"
aria-label="Find a category"
className="h-9 min-w-0 w-full border-0 bg-transparent text-sm outline-none"
/>
</label>
<button
type="button"
onClick={() => onSelect(null)}
aria-pressed={selectedId === null}
className="mx-2 mb-2 rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--admin-accent)]/10 aria-pressed:bg-[var(--admin-accent)]/15"
>
All categories
</button>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{visible.length === 0 && (
<p className="p-3 text-sm text-[var(--admin-text-muted)]">
No matching categories.
</p>
)}
<span className="min-w-0 flex-1 truncate">
{isRoot ? (
<span className="font-semibold">{node.caption}</span>
) : (
node.caption
{visible.map((node) => (
<div
key={node.id}
className={cn(
"flex items-center rounded-md",
selectedId === node.id && "bg-[var(--admin-accent)]/15",
)}
</span>
<span className="shrink-0 rounded-full bg-[var(--admin-accent)]/10 px-1.5 text-[0.6rem] font-semibold text-[var(--admin-accent)]">
style={{
paddingLeft: search.trim() ? 0 : Math.min(node.depth * 12, 60),
}}
>
{node.childCount > 0 && !search.trim() ? (
<button
type="button"
aria-label={`${collapsed.has(node.id) ? "Expand" : "Collapse"} ${node.caption}`}
aria-expanded={!collapsed.has(node.id)}
onClick={() => toggle(node.id)}
className="grid h-10 w-8 shrink-0 place-items-center rounded-md hover:bg-[var(--admin-accent)]/10"
>
{collapsed.has(node.id) ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
</button>
) : (
<span className="w-8 shrink-0" />
)}
<button
type="button"
onClick={() => onSelect(node.id)}
aria-pressed={selectedId === node.id}
title={node.caption}
className="flex min-h-10 min-w-0 flex-1 items-center gap-2 rounded-md py-2 pr-2 text-left text-sm hover:bg-[var(--admin-accent)]/10"
>
<span className="min-w-0 flex-1 truncate">{node.caption}</span>
<span className="text-xs tabular-nums text-[var(--admin-text-muted)]">
{node.itemCount}
</span>
</button>
);
})
)}
</div>
))}
</div>
</aside>
);
@@ -0,0 +1,200 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { adminFetch } from "@/lib/admin-fetch";
import {
compareFurniture,
type FurnitureInspection,
} from "@/lib/furni/studio-inspection";
import type { FurniItem } from "./studio-types";
export function useFurnitureInspection(classnames: string[]) {
const [attempt, setAttempt] = useState(0);
const key = JSON.stringify({ classnames, attempt });
const [state, setState] = useState<{
key: string;
items: FurnitureInspection[];
error: string;
loading: boolean;
}>({ key: "", items: [], error: "", loading: true });
useEffect(() => {
const abort = new AbortController();
setState({ key, items: [], error: "", loading: true });
void (async () => {
try {
const names = JSON.parse(key).classnames as string[];
const inspected: FurnitureInspection[] = [];
for (let offset = 0; offset < names.length; offset += 500) {
const res = await adminFetch("/api/admin/studio/inspect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classnames: names.slice(offset, offset + 500),
}),
signal: abort.signal,
});
const data = await res.json();
if (!res.ok || !Array.isArray(data.items))
throw new Error(data.error || "Could not inspect local furniture");
inspected.push(...data.items);
}
if (!abort.signal.aborted)
setState({ key, items: inspected, error: "", loading: false });
} catch (error) {
if (!abort.signal.aborted)
setState({
key,
items: [],
error: (error as Error).message,
loading: false,
});
}
})();
return () => abort.abort();
}, [key]);
return {
...state,
loading: state.key !== key || state.loading,
retry: () => setAttempt((value) => value + 1),
};
}
const status = (value: boolean | null | undefined) =>
value == null ? "Not verified" : value ? "Present" : "Missing";
export function FurnitureComparison({
item,
local,
}: {
item: FurniItem;
local: FurnitureInspection;
}) {
const comparison = compareFurniture(item, local);
return (
<div className="space-y-3 text-sm">
<dl className="grid grid-cols-2 gap-2">
{(
[
[".nitro", local.nitro.exists],
["Local icon", local.icon.exists],
[
"Furnidata",
local.furnidataReadable ? local.furnidata.length > 0 : null,
],
["SQL item", local.sql.length > 0],
["Catalog offer", local.catalog.length > 0],
] as const
).map(([label, present]) => (
<div
key={label}
className="rounded-md border border-[var(--admin-border)] p-2"
>
<dt className="text-xs text-[var(--admin-text-muted)]">{label}</dt>
<dd
className={
present
? "text-[var(--admin-success)]"
: "text-[var(--admin-warning)]"
}
>
{status(present)}
</dd>
</div>
))}
</dl>
{comparison.state === "conflict" && (
<p role="alert" className="text-[var(--admin-warning)]">
Multiple local records use this classname. Review them in Catalog
audit before importing.
</p>
)}
{!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]">
Local furnidata could not be read. Its values are not verified.
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr>
<th className="p-2 text-left">Field</th>
<th className="p-2 text-left">Local now</th>
<th className="p-2 text-left">Source</th>
</tr>
</thead>
<tbody>
{comparison.fields.map((field) => (
<tr
key={field.label}
className={field.changed ? "bg-[var(--admin-warning)]/10" : ""}
>
<th className="p-2 text-left">
{field.label}
{field.changed ? " · Changed" : ""}
</th>
<td className="max-w-48 break-words p-2">
{field.before ?? "—"}
</td>
<td className="max-w-48 break-words p-2">
{field.after ?? "Not supplied"}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-[var(--admin-text-muted)]">
Source values are compared with local data. Import may remap IDs and
translate names. Existing furniture keeps its local IDs and SQL
settings; new furniture receives a free local ID when the source ID is
occupied.
</p>
<details>
<summary className="cursor-pointer font-medium">
SQL records and catalog offers
</summary>
<div className="mt-2 space-y-2 text-xs">
{local.sql.length ? (
local.sql.map((row) => (
<p key={row.id}>
Item #{row.id} · Sprite #{row.spriteId} · {row.name}
</p>
))
) : (
<p>No local SQL item.</p>
)}
{local.catalog.map((row) => (
<p key={row.id}>
Offer #{row.id} · Page #{row.pageId} · {row.credits} credits +{" "}
{row.points} points
</p>
))}
</div>
</details>
</div>
);
}
export function FurnitureInspector({ item }: { item: FurniItem }) {
const inspection = useFurnitureInspection([item.classname]);
if (inspection.loading)
return (
<p role="status" className="py-3 text-sm">
Checking local data and assets…
</p>
);
if (inspection.error)
return (
<div role="alert">
<p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}>
Retry inspection
</Button>
</div>
);
const local = inspection.items.find(
(entry) => entry.classname === item.classname,
);
return local ? (
<FurnitureComparison item={item} local={local} />
) : (
<p>Local inspection unavailable.</p>
);
}
@@ -0,0 +1,318 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { compareFurniture } from "@/lib/furni/studio-inspection";
import {
FurnitureComparison,
useFurnitureInspection,
} from "./furniture-inspector";
import type { FurniItem } from "./studio-types";
import { useSourceAssetChecks } from "./use-source-asset-checks";
export function ImportReview({
items,
sourceId,
source,
translation,
onCancel,
onConfirm,
}: {
items: FurniItem[];
sourceId?: string;
source: string;
translation: string;
onCancel: () => void;
onConfirm: (items: FurniItem[]) => void;
}) {
const inspection = useFurnitureInspection(
items.map((item) => item.classname),
);
const assets = useSourceAssetChecks(items, sourceId, inspection);
const unavailable = assets.items.filter((item) => item.state === "missing");
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
const [step, setStep] = useState<"review" | "confirm">("review");
const [page, setPage] = useState(0);
const localByName = new Map(
inspection.items.map((item) => [item.classname, item]),
);
const complete = items.every((item) => localByName.has(item.classname));
const ready = items.filter((item) => {
const local = localByName.get(item.classname);
return local && compareFurniture(item, local).state !== "conflict";
});
const blocked = items.filter((item) => {
const local = localByName.get(item.classname);
return local && compareFurniture(item, local).state === "conflict";
}).length;
const existing = items.filter((item) => {
const local = localByName.get(item.classname);
return (
local &&
local.sql.length > 0 &&
compareFurniture(item, local).state !== "conflict"
);
}).length;
const pageItems = (step === "review" ? items : ready).slice(
page * 50,
(page + 1) * 50,
);
const pageCount = Math.max(
1,
Math.ceil((step === "review" ? items : ready).length / 50),
);
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onCancel();
}}
>
<DialogContent className="flex max-h-[90dvh] w-[calc(100vw-2rem)] max-w-3xl sm:max-w-3xl flex-col overflow-hidden border-[var(--admin-border)] bg-[var(--admin-surface)] text-[var(--admin-text)]">
<DialogHeader>
<DialogTitle>
{step === "review"
? "Review furniture import"
: "Confirm furniture import"}
</DialogTitle>
<DialogDescription>
1. Select → 2. Review → 3. Import
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
<div className="rounded-lg bg-[var(--admin-canvas)] p-3 text-sm">
<p>
<strong>Source:</strong> {source}
</p>
<p>
<strong>Destination:</strong> local catalog, furnidata, .nitro and
icons
</p>
<p>
<strong>Translation:</strong> {translation}
</p>
<p>
<strong>Selection:</strong> {items.length} furniture items
</p>
</div>
{assets.loading && !inspection.loading && (
<p role="status">
Checking source files… {assets.items.length} checked
</p>
)}
{assets.error && (
<div role="alert">
<p>{assets.error}</p>
<Button variant="outline" onClick={assets.retry}>
Retry source check
</Button>
</div>
)}
{assets.items.some((item) => item.state === "unknown") && (
<p className="text-sm text-[var(--admin-warning)]">
Some sources could not be verified. The import will still try to
download their files.
</p>
)}
{unavailable.map((check) => (
<section
key={check.classname}
className="rounded-lg border border-[var(--admin-border)] p-3 space-y-2"
>
<p className="text-sm font-medium">
Source files not found by preliminary check: {check.classname}
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
You can continue. The importer will try the original furniture
files and verify them before creating its SQL entry.
</p>
</section>
))}
{inspection.loading ? (
<p role="status">Checking local data and assets…</p>
) : inspection.error ? (
<div role="alert">
<p>{inspection.error}</p>
<Button variant="outline" onClick={inspection.retry}>
Retry inspection
</Button>
</div>
) : !complete ? (
<p role="alert">
Inspection is incomplete. Close and retry before importing.
</p>
) : (
<>
<p role="status" className="text-sm">
{ready.length} ready to import · {existing} already in SQL
(complete missing data) · {blocked} conflicts ·{" "}
{unavailable.length} source file warnings
</p>
{inspection.items.some(
(item) =>
!item.furnidataReadable ||
item.icon.exists === null ||
item.nitro.exists === null,
) && (
<p className="text-sm text-[var(--admin-warning)]">
Some local files could not be verified. Review the item
details before continuing.
</p>
)}
{step === "review" ? (
<div className="space-y-2">
{pageItems.map((item) => {
const local = localByName.get(item.classname);
if (!local) return null;
const comparison = compareFurniture(item, local);
return (
<div
key={item.classname}
className="rounded-lg border border-[var(--admin-border)]"
>
<button
type="button"
aria-expanded={expanded === item.classname}
onClick={() =>
setExpanded(
expanded === item.classname ? "" : item.classname,
)
}
className="flex min-h-11 w-full items-center justify-between gap-3 p-3 text-left text-sm"
>
<span className="min-w-0 break-words">
{item.name}
<span className="block text-xs text-[var(--admin-text-muted)]">
{item.classname}
</span>
</span>
<span>
{comparison.state === "new"
? "New"
: comparison.state === "changed"
? "Differences"
: comparison.state === "conflict"
? "Conflict"
: comparison.state === "unknown"
? "Not fully verified"
: "Present"}
</span>
</button>
{expanded === item.classname && (
<div className="border-t border-[var(--admin-border)] p-3">
<FurnitureComparison item={item} local={local} />
</div>
)}
</div>
);
})}
</div>
) : (
<>
<p className="text-sm">
Only the {ready.length} eligible items below will be
submitted. Existing furniture keeps its local IDs, SQL
settings and catalog offers. Missing assets and catalog
entries are completed; furnidata is refreshed. Conflicts are
excluded.
</p>
<p className="text-xs text-[var(--admin-text-muted)]">
Automatic categories and prices below are estimates. Source
rules may override them; final results appear in the import
summary.
</p>
<ul className="space-y-2">
{pageItems.map((item) => {
const placement = previewAutoCatalog(
item.classname,
item.type,
);
return (
<li
key={item.classname}
className="rounded-md border border-[var(--admin-border)] p-2 text-sm"
>
<strong>{item.name}</strong>
<p>
{placement.categoryLabel} · {placement.credits}{" "}
credits + {placement.points} points
</p>
</li>
);
})}
</ul>
</>
)}
</>
)}
</div>
{!inspection.loading && !inspection.error && pageCount > 1 && (
<div className="flex items-center justify-between gap-2 text-sm">
<Button
variant="outline"
disabled={page === 0}
onClick={() => setPage((value) => value - 1)}
>
Previous items
</Button>
<span>
Page {page + 1} / {pageCount}
</span>
<Button
variant="outline"
disabled={page + 1 === pageCount}
onClick={() => setPage((value) => value + 1)}
>
Next items
</Button>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={
step === "confirm"
? () => {
setStep("review");
setPage(0);
}
: onCancel
}
>
{step === "confirm" ? "Back to review" : "Cancel"}
</Button>
<Button
style={{
backgroundColor: "var(--admin-accent)",
color: "var(--admin-accent-foreground)",
}}
disabled={
inspection.loading ||
!!inspection.error ||
!complete ||
ready.length === 0
}
onClick={() => {
if (step === "review") {
setStep("confirm");
setPage(0);
} else onConfirm(ready);
}}
>
{step === "review"
? "Continue to confirmation"
: `Start import (${ready.length})`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,80 @@
"use client";
import { useEffect, useState } from "react";
import { adminFetch } from "@/lib/admin-fetch";
import type { SourceAssetCheck } from "@/lib/furni/source-assets";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
import type { FurniItem } from "./studio-types";
export function useSourceAssetChecks(
items: FurniItem[],
sourceId: string | undefined,
local: { items: FurnitureInspection[]; loading: boolean; error: string },
) {
const [attempt, setAttempt] = useState(0);
const needed = items.filter(
(item) =>
!local.items.some(
(entry) =>
entry.classname === item.classname && entry.nitro.exists === true,
),
);
const key =
local.loading || local.error
? ""
: JSON.stringify({ items: needed, sourceId, attempt });
const [state, setState] = useState<{
key: string;
loading: boolean;
error: string;
items: SourceAssetCheck[];
}>({ key: "", loading: true, error: "", items: [] });
useEffect(() => {
if (!key) return;
const abort = new AbortController();
setState({ key, loading: true, error: "", items: [] });
void (async () => {
const { items, sourceId } = JSON.parse(key) as {
items: FurniItem[];
sourceId?: string;
};
const results: SourceAssetCheck[] = [];
try {
for (let offset = 0; offset < items.length; offset += 20) {
const res = await adminFetch("/api/admin/studio/source-assets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: items.slice(offset, offset + 20),
sourceId,
}),
signal: abort.signal,
});
const data = await res.json();
if (!res.ok || !Array.isArray(data.items))
throw Error(data.error || "Could not check source assets");
results.push(...data.items);
if (!abort.signal.aborted)
setState({ key, items: [...results], loading: true, error: "" });
}
if (results.length !== items.length)
throw Error("Source check is incomplete. Retry before importing.");
if (!abort.signal.aborted)
setState({ key, items: results, loading: false, error: "" });
} catch (error) {
if (!abort.signal.aborted)
setState({
key,
items: [],
loading: false,
error: (error as Error).message,
});
}
})();
return () => abort.abort();
}, [key]);
return {
...state,
loading: !key || state.key !== key || state.loading,
items: state.key === key ? state.items : [],
retry: () => setAttempt((value) => value + 1),
};
}
+5 -1
View File
@@ -1,9 +1,12 @@
"use client";
import { Lenis } from "lenis/react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
export function SmoothScroll() {
const pathname = usePathname();
const isAdmin = pathname === "/admin" || pathname?.startsWith("/admin/");
const [prefersReduced, setPrefersReduced] = useState(true);
useEffect(() => {
@@ -14,7 +17,8 @@ export function SmoothScroll() {
return () => mq.removeEventListener("change", onChange);
}, []);
if (prefersReduced) return null;
// Admin panels own their scroll containers; root wheel smoothing consumes their events.
if (prefersReduced || isAdmin) return null;
return (
<Lenis
+41 -2
View File
@@ -1,9 +1,15 @@
import { type NextRequest, NextResponse } from "next/server";
import { after, type NextRequest, NextResponse } from "next/server";
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
import { validateCsrfToken } from "@/lib/foundation/security";
import { canAccess, getApiAdminContext } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
import {
beginCatalogExport,
catalogExportEnabled,
isCatalogMutation,
} from "@/lib/services/catalog-git-queue";
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
@@ -80,9 +86,42 @@ export function withAdmin(
{ status: 403 },
);
}
let finishExport: (() => Promise<void>) | undefined;
try {
return await handler(request, context, routeContext);
if (
catalogExportEnabled() &&
isCatalogMutation(request.method, request.nextUrl.pathname)
) {
finishExport = await beginCatalogExport();
}
const response = await handler(request, context, routeContext);
if (
finishExport &&
response.body &&
response.headers.get("content-type")?.includes("text/event-stream")
) {
const [client, completion] = response.body.tee();
const finish = finishExport;
after(async () => {
const reader = completion.getReader();
try {
while (!(await reader.read()).done) {
/* Wait for all import results. */
}
} finally {
reader.releaseLock();
await finish();
}
});
return new Response(client, {
status: response.status,
headers: response.headers,
});
}
await finishExport?.();
return response;
} catch (error) {
await finishExport?.().catch(() => undefined);
logServerError("admin.api_failed", error, {
path: request.nextUrl.pathname,
userId: context.session.user.id,
+87
View File
@@ -0,0 +1,87 @@
import { mkdtemp } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
after: vi.fn(),
auth: vi.fn(),
csrf: vi.fn(),
}));
vi.mock("next/server", async (original) => ({
...(await original<typeof import("next/server")>()),
after: mocks.after,
}));
vi.mock("@/lib/admin/authorization-events", () => ({
logAuthorizationEvent: vi.fn(),
}));
vi.mock("@/lib/foundation/security", () => ({ validateCsrfToken: mocks.csrf }));
vi.mock("@/lib/permissions", () => ({
getApiAdminContext: mocks.auth,
canAccess: () => true,
}));
vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() }));
import { withAdmin } from "./api-handler";
import { catalogExportQueue } from "./services/catalog-git-queue";
describe("catalog API completion tracking", () => {
beforeEach(async () => {
vi.stubEnv("CATALOG_GIT_CHECKOUT", "/catalog");
vi.stubEnv(
"CATALOG_GIT_STATE_DIR",
await mkdtemp(path.join(os.tmpdir(), "catalog-api-")),
);
mocks.auth.mockResolvedValue({
session: { user: { id: 1, rank: 7 } },
permissions: [],
});
mocks.csrf.mockResolvedValue(true);
mocks.after.mockReset();
});
afterEach(() => vi.unstubAllEnvs());
it("waits for the last SSE event before making changes publishable", async () => {
let end: (() => void) | undefined;
const response = new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('data: {"type":"progress"}\n\n'),
);
end = () => controller.close();
},
}),
{ headers: { "content-type": "text/event-stream" } },
);
const route = withAdmin({}, async () => response);
const returned = await route(
new NextRequest("http://localhost/api/admin/import/furni/batch", {
method: "POST",
}),
);
expect(await catalogExportQueue().batch()).toBeNull();
const complete = mocks.after.mock.calls[0][0]();
end?.();
await returned.text();
await complete;
expect(await catalogExportQueue().batch()).toHaveLength(1);
});
it("does not queue unauthenticated requests or reads", async () => {
const handler = vi.fn(async () => new Response("{}"));
const route = withAdmin({}, handler);
mocks.auth.mockResolvedValueOnce(null);
expect(
(
await route(
new NextRequest("http://localhost/api/admin/import/furni", {
method: "POST",
}),
)
).status,
).toBe(401);
expect(handler).not.toHaveBeenCalled();
await route(new NextRequest("http://localhost/api/admin/import/furni"));
expect(await catalogExportQueue().batch()).toHaveLength(0);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { readSseStream } from "@/lib/sse-client";
interface ImportResponse {
status: number;
data: Record<string, unknown>;
}
export async function readImportResponse(
response: Response,
): Promise<ImportResponse> {
if (
response.ok &&
response.headers.get("content-type")?.includes("text/event-stream")
) {
if (!response.body)
throw Error(
"Import response is empty. Check the furniture status before retrying.",
);
let result: ImportResponse | undefined;
await readSseStream(response.body, (event) => {
if (
event.type === "result" &&
typeof event.status === "number" &&
event.data &&
typeof event.data === "object"
)
result = {
status: event.status,
data: event.data as Record<string, unknown>,
};
});
if (!result)
throw Error(
"Import connection ended before confirmation. Some changes may already be saved. Check the furniture status before retrying.",
);
return result;
}
const text = await response.text();
let data: Record<string, unknown>;
try {
data = JSON.parse(text);
} catch {
const reason = [502, 503, 504, 524].includes(response.status)
? "Server or proxy unavailable, or request timed out"
: "Server returned an unexpected response";
throw Error(
`${reason} (HTTP ${response.status}). Check the furniture status before retrying.`,
);
}
if (!data || typeof data !== "object")
throw Error(`Invalid import response (HTTP ${response.status}).`);
return { status: response.status, data };
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { indexLocalFurniture, localFurnitureStatus } from "./local-presence";
const index = indexLocalFurniture([
{ id: 2000037258, itemName: "recycler_kintsugib" },
]);
describe("local furniture recognition", () => {
it("recognizes casing differences and uses the allocated catalog ID", () => {
expect(
localFurnitureStatus(
"recycler_kintsugiB",
index,
new Set(["recycler_kintsugib"]),
new Set([2000037258]),
),
).toEqual({
alreadyImported: true,
localId: 2000037258,
hasFurnidata: true,
hasCatalogEntry: true,
});
});
it("does not confuse the base furniture with its named variant", () => {
expect(
localFurnitureStatus(
"recycler_kintsugi",
index,
new Set(),
new Set([16940]),
).alreadyImported,
).toBe(false);
});
it("does not identify a source-ID collision as a matching catalog offer", () => {
expect(
localFurnitureStatus(
"recycler_kintsugiB",
index,
new Set(),
new Set([16940]),
).hasCatalogEntry,
).toBe(false);
});
it("retains separate color variants and duplicate SQL records", () => {
const rows = indexLocalFurniture([
{ id: 1, itemName: "chair*0" },
{ id: 2, itemName: "CHAIR*0" },
{ id: 3, itemName: "chair*1" },
]);
expect(rows.get("chair*0")).toHaveLength(2);
expect(rows.get("chair*1")).toHaveLength(1);
expect(
localFurnitureStatus("chair*0", rows, new Set(), new Set()).localId,
).toBeUndefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { normalizeClassname } from "./classname";
export function indexLocalFurniture<T extends { id: number; itemName: string }>(
rows: T[],
) {
const index = new Map<string, T[]>();
for (const row of rows) {
const key = normalizeClassname(row.itemName);
const matches = index.get(key) ?? [];
matches.push(row);
index.set(key, matches);
}
return index;
}
export function localFurnitureStatus(
classname: string,
local: Map<string, Array<{ id: number; itemName: string }>>,
furnidata: Set<string>,
catalogIds: Set<number>,
) {
const key = normalizeClassname(classname);
const rows = local.get(key) ?? [];
return {
alreadyImported: rows.length > 0,
localId: rows.length === 1 ? rows[0].id : undefined,
hasFurnidata: furnidata.has(key),
hasCatalogEntry: rows.some((row) => catalogIds.has(row.id)),
};
}
+16
View File
@@ -0,0 +1,16 @@
export interface SourceAssetItem {
id: number;
classname: string;
name: string;
description: string;
type: string;
revision: number;
category: string;
}
export type AssetAvailability = "available" | "missing" | "unknown";
export interface SourceAssetCheck {
classname: string;
state: AssetAvailability;
revision: number;
alternatives: SourceAssetItem[];
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
compareFurniture,
type FurnitureInspection,
validateClassnames,
} from "./studio-inspection";
const source = {
name: "Chair",
description: "A chair",
revision: 2,
type: "flooritem",
};
const empty: FurnitureInspection = {
classname: "chair",
sql: [],
catalog: [],
furnidata: [],
furnidataReadable: true,
nitro: { exists: false, bytes: 0 },
icon: { exists: false, bytes: 0 },
};
describe("studio inspection", () => {
it("rejects paths and oversized batches", () => {
expect(validateClassnames(["../file"])).toBeNull();
expect(validateClassnames(["a\\b"])).toBeNull();
expect(validateClassnames(Array(501).fill("chair"))).toBeNull();
expect(validateClassnames(["chair*0", "chair*0"])).toEqual(["chair*0"]);
});
it("identifies new furniture only after a successful lookup", () => {
expect(compareFurniture(source, empty).state).toBe("new");
expect(
compareFurniture(source, { ...empty, furnidataReadable: false }).state,
).toBe("unknown");
});
it("compares values without treating allocated IDs as conflicts", () => {
const local = {
...empty,
sql: [{ id: 901, spriteId: 902, name: "Old chair", type: "s" }],
furnidata: [
{
id: 902,
name: "Old chair",
description: "Old",
revision: 1,
type: "flooritem",
},
],
};
expect(compareFurniture(source, local).state).toBe("changed");
expect(
compareFurniture(source, local).fields.find((f) => f.label === "Name")
?.changed,
).toBe(true);
});
it("flags duplicate SQL classnames as conflicts", () => {
expect(
compareFurniture(source, {
...empty,
sql: [
{ id: 1, spriteId: 1, name: "Chair", type: "s" },
{ id: 2, spriteId: 2, name: "Chair", type: "s" },
],
}).state,
).toBe("conflict");
});
});
+70
View File
@@ -0,0 +1,70 @@
export interface FurnitureInspection {
classname: string;
sql: Array<{ id: number; spriteId: number; name: string; type: string }>;
catalog: Array<{
id: number;
pageId: number;
credits: number;
points: number;
}>;
furnidata: Array<{
id: number;
name: string;
description: string;
revision: number;
type: string;
}>;
furnidataReadable: boolean;
nitro: { exists: boolean | null; bytes: number };
icon: { exists: boolean | null; bytes: number };
}
export function validateClassnames(value: unknown): string[] | null {
if (
!Array.isArray(value) ||
!value.length ||
value.length > 500 ||
value.some(
(v) =>
typeof v !== "string" || v.length > 200 || !/^[a-zA-Z0-9_*-]+$/.test(v),
)
)
return null;
return [...new Set(value)];
}
export function compareFurniture(
source: { name: string; description: string; revision: number; type: string },
local: FurnitureInspection,
) {
const fd = local.furnidata[0],
sql = local.sql[0];
const values = [
["Name", sql?.name ?? fd?.name, source.name],
["Description", fd?.description, source.description],
["Revision", fd?.revision, source.revision || undefined],
[
"Type",
sql ? (sql.type === "i" ? "wallitem" : "flooritem") : fd?.type,
source.type,
],
] as const;
const fields = values.map(([label, before, after]) => ({
label,
before,
after,
changed:
before !== undefined &&
after !== undefined &&
String(before) !== String(after),
}));
const state =
local.sql.length > 1 || local.furnidata.length > 1
? "conflict"
: !local.furnidataReadable
? "unknown"
: !sql && !fd
? "new"
: fields.some((f) => f.changed)
? "changed"
: "present";
return { state, fields } as const;
}
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
catalogGitEnvironment,
decryptCatalogToken,
publicCatalogConfig,
validateCatalogConfig,
} from "./catalog-git-config";
const input = {
enabled: true,
remote: "https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily",
branch: "Beta-3",
username: "remco",
token: "private-token",
};
describe("Gitea catalog configuration", () => {
beforeEach(() => vi.stubEnv("AUTH_SECRET", "test-auth-secret"));
afterEach(() => vi.unstubAllEnvs());
it("supports self-hosted Gitea and encrypts the token", () => {
const config = validateCatalogConfig(input, null);
expect(config.remote).toBe(`${input.remote}.git`);
expect(config.encryptedToken).not.toContain(input.token);
expect(decryptCatalogToken(config.encryptedToken)).toBe(input.token);
expect(JSON.stringify(publicCatalogConfig(config))).not.toContain(
input.token,
);
expect(publicCatalogConfig(config).provider).toBe("gitea");
});
it("preserves saved token only for the same repository and user", () => {
const previous = validateCatalogConfig(input, null);
expect(
validateCatalogConfig({ ...input, token: "" }, previous).encryptedToken,
).toBe(previous.encryptedToken);
expect(() =>
validateCatalogConfig(
{ ...input, token: "", remote: "https://other.example/user/repo" },
previous,
),
).toThrow();
});
it("rejects embedded credentials and unsafe branches", () => {
expect(() =>
validateCatalogConfig(
{ ...input, remote: "https://user:secret@host/user/repo" },
null,
),
).toThrow();
for (const branch of [
"--help",
"main..evil",
"../branch",
"a.lock",
"a\\b",
])
expect(() => validateCatalogConfig({ ...input, branch }, null)).toThrow();
});
it("supplies credentials through environment and disables redirects", () => {
const config = validateCatalogConfig(input, null);
const env = catalogGitEnvironment(config);
expect(env.GIT_CONFIG_KEY_0).toBe(
"http.https://gitlab.epicnabbo.nl/.extraHeader",
);
expect(env.GIT_CONFIG_VALUE_1).toBe("false");
});
});
+177
View File
@@ -0,0 +1,177 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
randomUUID,
} from "node:crypto";
import { promises as fs, readFileSync } from "node:fs";
import path from "node:path";
import { CATALOG_BRANCH, CATALOG_REMOTE } from "./catalog-git-core";
export interface ManagedCatalogConfig {
enabled: boolean;
remote: string;
branch: string;
username: string;
encryptedToken: string;
}
export function catalogStateRoot() {
return (
process.env.CATALOG_GIT_STATE_DIR ||
path.join(process.cwd(), "storage", "catalog-git")
);
}
export function readManagedCatalogConfig(): ManagedCatalogConfig | null {
try {
return JSON.parse(
readFileSync(path.join(catalogStateRoot(), "config.json"), "utf8"),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw new Error("Catalog Git configuration cannot be read");
}
}
function key() {
const secret = process.env.AUTH_SECRET;
if (!secret)
throw new Error("The application authentication secret is unavailable");
return createHash("sha256").update(secret).digest();
}
export function encryptCatalogToken(token: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const value = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
return [iv, cipher.getAuthTag(), value]
.map((part) => part.toString("base64"))
.join(".");
}
export function decryptCatalogToken(value: string) {
if (!value) return "";
const [iv, tag, data] = value
.split(".")
.map((part) => Buffer.from(part, "base64"));
const cipher = createDecipheriv("aes-256-gcm", key(), iv);
cipher.setAuthTag(tag);
return Buffer.concat([cipher.update(data), cipher.final()]).toString("utf8");
}
export function validateCatalogConfig(
input: unknown,
previous: ManagedCatalogConfig | null,
): ManagedCatalogConfig {
const value = input as Record<string, unknown>;
if (
!value ||
typeof value.remote !== "string" ||
typeof value.branch !== "string" ||
typeof value.username !== "string" ||
typeof value.enabled !== "boolean"
)
throw new Error(
"Repository, branch, username and enabled state are required",
);
let url: URL;
try {
url = new URL(value.remote.trim());
} catch {
throw new Error("Enter a valid HTTPS Gitea repository URL");
}
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash ||
!/^\/(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+(?:\.git)?\/?$/.test(url.pathname)
)
throw new Error(
"Use an HTTPS repository URL without credentials or query parameters",
);
const remote = `${url
.toString()
.replace(/\/$/, "")
.replace(/\.git$/, "")}.git`;
const branch = value.branch.trim(),
username = value.username.trim();
if (
!branch ||
branch.startsWith("-") ||
branch.startsWith("/") ||
branch.endsWith("/") ||
branch.endsWith(".") ||
branch.includes("..") ||
branch.includes("//") ||
branch.includes("@{") ||
/[\s~^:?*[\\]/.test(branch) ||
[...branch].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
) ||
branch
.split("/")
.some((part) => part.startsWith(".") || part.endsWith(".lock")) ||
branch === "@"
)
throw new Error("Enter a valid Git branch");
if (!username || /[\r\n:]/.test(username))
throw new Error("Enter a valid Gitea username");
const token = typeof value.token === "string" ? value.token.trim() : "";
if (/[\r\n]/.test(token)) throw new Error("Invalid access token");
const same = previous?.remote === remote && previous.username === username;
const encryptedToken = token
? encryptCatalogToken(token)
: same
? previous.encryptedToken
: "";
if (value.enabled && !encryptedToken)
throw new Error("Provide a Gitea token before enabling export");
return { enabled: value.enabled, remote, branch, username, encryptedToken };
}
export function publicCatalogConfig(config = readManagedCatalogConfig()) {
return {
provider: "gitea",
enabled: config?.enabled ?? false,
remote: config?.remote ?? CATALOG_REMOTE,
branch: config?.branch ?? CATALOG_BRANCH,
username: config?.username ?? "",
hasToken: !!config?.encryptedToken,
};
}
export async function saveCatalogConfig(config: ManagedCatalogConfig) {
const root = catalogStateRoot();
await fs.mkdir(root, { recursive: true });
const temp = path.join(root, `config-${randomUUID()}.tmp`);
try {
await fs.writeFile(temp, JSON.stringify(config), {
mode: 0o600,
flag: "wx",
});
await fs.rename(temp, path.join(root, "config.json"));
} finally {
await fs.rm(temp, { force: true });
}
}
export function catalogGitEnvironment(
config: ManagedCatalogConfig,
): NodeJS.ProcessEnv {
const token = decryptCatalogToken(config.encryptedToken);
return {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_COUNT: "2",
GIT_CONFIG_KEY_0: `http.${new URL(config.remote).origin}/.extraHeader`,
GIT_CONFIG_VALUE_0:
"Authorization: Basic " +
Buffer.from(`${config.username}:${token}`).toString("base64"),
GIT_CONFIG_KEY_1: "http.followRedirects",
GIT_CONFIG_VALUE_1: "false",
};
}
export function managedCatalogCheckout(config: ManagedCatalogConfig) {
return path.join(
catalogStateRoot(),
"checkouts",
createHash("sha256")
.update(`${config.remote}\n${config.branch}`)
.digest("hex")
.slice(0, 24),
);
}
+104
View File
@@ -0,0 +1,104 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
CatalogExportQueue,
publishCatalogFiles,
recoverCatalogQueue,
sqlValue,
} from "./catalog-git-core";
describe("catalog export", () => {
it("recovers queue entries owned by a terminated local process", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "catalog-recovery-"));
const deadPid = Number(
execFileSync(
process.execPath,
["-e", "process.stdout.write(String(process.pid))"],
{ encoding: "utf8" },
),
);
const owner = JSON.stringify({ pid: deadPid, host: os.hostname() });
await writeFile(path.join(root, "12345678.active"), owner);
await writeFile(path.join(root, "worker.lock"), owner);
await recoverCatalogQueue(root);
const q = new CatalogExportQueue(root);
expect(await q.batch()).toEqual(["12345678.pending"]);
expect(await q.entries()).not.toContain("worker.lock");
});
it("keeps new requests pending when an older snapshot completes", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "catalog-queue-"));
const q = new CatalogExportQueue(root);
const finish = await q.begin();
expect(await q.batch()).toBeNull();
await finish();
const batch = await q.batch();
expect(batch).toHaveLength(1);
await q.request();
if (!batch) throw new Error("Expected pending batch");
await q.complete(batch);
expect(await q.batch()).toHaveLength(1);
});
it("encodes SQL strings without quote or backslash ambiguity", () => {
expect(sqlValue("Valentine's \\ Day\n")).toBe(
"CONVERT(X'56616c656e74696e652773205c204461790a' USING utf8mb4)",
);
expect(sqlValue(null)).toBe("NULL");
expect(sqlValue(42)).toBe("42");
expect(() => sqlValue(Number.NaN)).toThrow();
});
it("publishes to a bare remote, retries a failed push and avoids empty commits", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "catalog-git-"));
const remote = path.join(root, "remote.git");
const checkout = path.join(root, "checkout");
const git = (cwd: string, ...args: string[]) =>
execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
git(root, "init", "--bare", remote);
git(root, "clone", remote, checkout);
git(checkout, "checkout", "-b", "Beta-3");
git(checkout, "config", "user.name", "Catalog test");
git(checkout, "config", "user.email", "[email protected]");
await writeFile(path.join(checkout, "readme.md"), "preserved");
git(checkout, "add", "readme.md");
git(checkout, "commit", "-m", "initial");
git(checkout, "push", "-u", "origin", "Beta-3");
const source = path.join(root, "chair.nitro");
await writeFile(source, "bundle");
const files = [
{ source, target: "Gamedata/bundled/furniture/chair.nitro" },
];
const options = { checkout, remote, branch: "Beta-3", files };
const first = await publishCatalogFiles(options);
expect(await publishCatalogFiles(options)).toBe(first);
await writeFile(source, "updated");
git(
checkout,
"remote",
"set-url",
"--push",
"origin",
path.join(root, "missing.git"),
);
await expect(publishCatalogFiles(options)).rejects.toThrow();
git(checkout, "remote", "set-url", "--push", "origin", remote);
const retried = await publishCatalogFiles(options);
expect(retried).not.toBe(first);
expect(git(remote, "rev-parse", "Beta-3")).toBe(retried);
expect(await readFile(path.join(checkout, "readme.md"), "utf8")).toBe(
"preserved",
);
await expect(
publishCatalogFiles({
...options,
files: [{ source, target: "../escape" }],
}),
).rejects.toThrow();
await mkdir(path.join(checkout, "unrelated"));
await writeFile(path.join(checkout, "unrelated", "local.txt"), "local");
await expect(publishCatalogFiles(options)).rejects.toThrow(/clean/);
// This integration test launches multiple real Git processes on the CI runner.
}, 60_000);
});
+231
View File
@@ -0,0 +1,231 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { hostname } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
const exec = promisify(execFile);
export const CATALOG_REMOTE =
"https://gitlab.epicnabbo.nl/remco/Epicnabbo-Catalogus-Updated-Daily.git";
export const CATALOG_BRANCH = "Beta-3";
export const CATALOG_SQL_ROOT = "catalogue version 2 ( Final (Dev)/sqls";
export const CATALOG_LANGUAGE_ROOT =
"catalogue version 2 ( Final (Dev)/langs furnidata";
export class CatalogExportQueue {
constructor(readonly root: string) {}
async entries() {
await fs.mkdir(this.root, { recursive: true });
return (await fs.readdir(this.root)).sort();
}
async begin() {
await this.entries();
const id = randomUUID();
await fs.writeFile(
path.join(this.root, `${id}.active`),
JSON.stringify({
pid: process.pid,
host: hostname(),
startedAt: new Date().toISOString(),
}),
{ flag: "wx" },
);
let finished = false;
return async () => {
if (finished) return;
await fs.rename(
path.join(this.root, `${id}.active`),
path.join(this.root, `${id}.pending`),
);
finished = true;
};
}
async request() {
await (await this.begin())();
}
async batch(): Promise<string[] | null> {
const entries = await this.entries();
if (entries.some((f) => f.endsWith(".active"))) return null;
return entries.filter((f) => f.endsWith(".pending"));
}
async complete(batch: string[]) {
for (const file of batch) {
if (!/^[a-f0-9-]+\.pending$/.test(file))
throw new Error("Invalid queue entry");
await fs.rm(path.join(this.root, file), { force: true });
}
}
}
export function sqlValue(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new Error("Invalid SQL number");
return String(value);
}
if (typeof value === "bigint") return String(value);
if (typeof value === "boolean") return value ? "1" : "0";
if (Buffer.isBuffer(value)) return `X'${value.toString("hex")}'`;
const text =
value instanceof Date
? value.toISOString().slice(0, 23).replace("T", " ")
: String(value);
return text.length
? `CONVERT(X'${Buffer.from(text, "utf8").toString("hex")}' USING utf8mb4)`
: "''";
}
export interface CatalogFile {
source: string;
target: string;
}
export function catalogTarget(root: string, target: string) {
if (
target.includes("\\") ||
target.split("/").some((s) => s === ".." || s === ".git") ||
path.isAbsolute(target)
) {
throw new Error("Invalid catalog target");
}
const result = path.resolve(root, target);
if (!result.startsWith(`${path.resolve(root)}${path.sep}`))
throw new Error("Invalid catalog target");
if (
!target.startsWith("Gamedata/") &&
!target.startsWith(`${CATALOG_SQL_ROOT}/`) &&
!target.startsWith(`${CATALOG_LANGUAGE_ROOT}/`)
) {
throw new Error("Unmanaged catalog target");
}
return result;
}
export async function publishCatalogFiles(options: {
checkout: string;
remote: string;
branch: string;
files: CatalogFile[];
env?: NodeJS.ProcessEnv;
}) {
const { checkout, remote, branch, files } = options;
const git = async (...args: string[]) => {
const { stdout } = await exec("git", args, {
cwd: checkout,
timeout: 120_000,
maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, ...options.env, GIT_TERMINAL_PROMPT: "0" },
});
return stdout.trim();
};
for (const file of files) catalogTarget(checkout, file.target);
if ((await git("remote", "get-url", "origin")) !== remote)
throw new Error("Unexpected catalog origin");
if ((await git("branch", "--show-current")) !== branch)
throw new Error("Unexpected catalog branch");
if (await git("status", "--porcelain"))
throw new Error("Catalog checkout must be clean");
try {
await git("pull", "--rebase", "origin", branch);
} catch (error) {
await git("rebase", "--abort").catch(() => undefined);
throw error;
}
const created: string[] = [];
let committed = false;
try {
for (const file of files) {
const target = catalogTarget(checkout, file.target);
// Reject symlink ancestors, including the target itself.
let ancestor = target;
while (ancestor !== path.resolve(checkout)) {
const stat = await fs
.lstat(ancestor)
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (stat?.isSymbolicLink())
throw new Error("Symlink in catalog target");
ancestor = path.dirname(ancestor);
}
const exists = await fs.stat(target).then(
() => true,
() => false,
);
if (!exists) created.push(target);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.copyFile(file.source, target);
}
// Only explicitly exported paths are eligible for staging.
for (let i = 0; i < files.length; i += 50) {
await git(
"--literal-pathspecs",
"add",
"--",
...files.slice(i, i + 50).map((f) => f.target),
);
}
if (await git("diff", "--cached", "--name-only")) {
await git(
"-c",
"user.name=Catalog Studio",
"-c",
"[email protected]",
"commit",
"-m",
"Update catalog assets and SQL from Catalog Studio",
);
}
committed = true;
// Also retries commits retained after a previous failed push.
await git("push", "origin", `HEAD:refs/heads/${branch}`);
return await git("rev-parse", "HEAD");
} catch (error) {
if (!committed) {
// The checkout was clean at entry; restore only this attempt's changes.
await git("restore", "--staged", "--worktree", ".");
for (const file of created) await fs.rm(file, { force: true });
}
throw error;
}
}
export async function recoverCatalogQueue(root: string) {
for (const name of await fs.readdir(root)) {
if (!name.endsWith(".active") && name !== "worker.lock") continue;
const file = path.join(root, name);
let owner: { pid: number; host: string };
try {
owner = JSON.parse(await fs.readFile(file, "utf8"));
} catch {
continue;
}
if (
owner.host !== hostname() ||
!Number.isInteger(owner.pid) ||
owner.pid <= 0
)
continue;
try {
process.kill(owner.pid, 0);
continue;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ESRCH") continue;
}
// Recheck ownership before recovering a dead process's entry.
if (
(await fs.readFile(file, "utf8").catch(() => "")) !==
JSON.stringify(owner)
)
continue;
if (name === "worker.lock") await fs.rm(file, { force: true });
else
await fs
.rename(file, path.join(root, name.replace(/\.active$/, ".pending")))
.catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
}
@@ -0,0 +1,57 @@
import { mkdtemp, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ snapshot: vi.fn(), publish: vi.fn() }));
vi.mock("./catalog-git-snapshot", () => ({
createCatalogSnapshot: mocks.snapshot,
}));
vi.mock("./catalog-git-core", async (original) => ({
...(await original<typeof import("./catalog-git-core")>()),
publishCatalogFiles: mocks.publish,
}));
import { runCatalogExport } from "./catalog-git-export";
import { catalogExportQueue, withCatalogExport } from "./catalog-git-queue";
describe("export worker", () => {
beforeEach(async () => {
vi.stubEnv("CATALOG_GIT_CHECKOUT", "/configured/checkout");
vi.stubEnv(
"CATALOG_GIT_STATE_DIR",
await mkdtemp(path.join(os.tmpdir(), "catalog-worker-")),
);
mocks.snapshot.mockReset().mockResolvedValue([]);
mocks.publish.mockReset().mockResolvedValue("abc123");
});
afterEach(() => vi.unstubAllEnvs());
it("retains failed work, retries it and clears only completed events", async () => {
const q = catalogExportQueue();
await q.request();
mocks.publish.mockRejectedValueOnce(new Error("credential secret"));
await runCatalogExport();
expect(await q.batch()).toHaveLength(1);
expect(
await readFile(path.join(q.root, "status.json"), "utf8"),
).not.toContain("credential secret");
await runCatalogExport();
expect(await q.batch()).toHaveLength(0);
expect(mocks.publish).toHaveBeenCalledTimes(2);
});
it("does not publish during an operation or if one starts while capturing", async () => {
const q = catalogExportQueue();
await q.request();
await withCatalogExport(async () => {
await runCatalogExport();
});
expect(mocks.snapshot).not.toHaveBeenCalled();
mocks.snapshot.mockImplementationOnce(async () => {
await q.request();
return [];
});
await runCatalogExport();
expect(mocks.publish).not.toHaveBeenCalled();
expect(await q.batch()).toHaveLength(3);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { promises as fs } from "node:fs";
import { hostname } from "node:os";
import path from "node:path";
import {
catalogGitEnvironment,
readManagedCatalogConfig,
} from "./catalog-git-config";
import {
CATALOG_BRANCH,
CATALOG_REMOTE,
publishCatalogFiles,
recoverCatalogQueue,
} from "./catalog-git-core";
import { ensureManagedCatalogCheckout } from "./catalog-git-managed";
import { catalogExportEnabled, catalogExportQueue } from "./catalog-git-queue";
import { createCatalogSnapshot } from "./catalog-git-snapshot";
export async function runCatalogExport() {
if (!catalogExportEnabled()) return;
const queue = catalogExportQueue();
await queue.entries();
await recoverCatalogQueue(queue.root);
const lock = await fs
.open(path.join(queue.root, "worker.lock"), "wx")
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "EEXIST") return null;
throw error;
});
if (!lock) return;
let snapshot: string | undefined;
const status = async (value: Record<string, unknown>) => {
const temp = path.join(queue.root, "status.tmp");
let previous: Record<string, unknown> = {};
try {
previous = JSON.parse(
await fs.readFile(path.join(queue.root, "status.json"), "utf8"),
);
} catch {
/* First attempt. */
}
await fs.writeFile(
temp,
JSON.stringify({ ...previous, error: undefined, ...value }),
);
await fs.rename(temp, path.join(queue.root, "status.json"));
};
try {
await lock.writeFile(
JSON.stringify({ pid: process.pid, host: hostname() }),
);
const batch = await queue.batch();
if (!batch?.length) return;
snapshot = await fs.mkdtemp(path.join(queue.root, "snapshot-"));
const files = await createCatalogSnapshot(snapshot);
// Any operation started/completed during capture invalidates the snapshot.
const current = await queue.batch();
if (!current || JSON.stringify(current) !== JSON.stringify(batch)) return;
const config = readManagedCatalogConfig();
if (config && !config.enabled) return;
const checkout = config
? await ensureManagedCatalogCheckout(config)
: process.env.CATALOG_GIT_CHECKOUT?.trim();
if (!checkout) return;
const commit = await publishCatalogFiles({
checkout,
remote: config?.remote ?? CATALOG_REMOTE,
branch: config?.branch ?? CATALOG_BRANCH,
env: config ? catalogGitEnvironment(config) : undefined,
files,
});
await queue.complete(batch);
await status({ commit, finishedAt: new Date().toISOString() });
} catch {
// Do not expose credentials or Git stderr through the admin API.
await status({
error:
"Export failed. Check repository access, branch, clean checkout and asset/database availability. Pending changes will be retried.",
finishedAt: new Date().toISOString(),
});
} finally {
try {
if (
snapshot &&
path.resolve(snapshot).startsWith(path.resolve(queue.root) + path.sep)
) {
await fs.rm(snapshot, { recursive: true, force: true });
}
} finally {
await lock.close();
await fs.rm(path.join(queue.root, "worker.lock"), { force: true });
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import {
catalogGitEnvironment,
type ManagedCatalogConfig,
managedCatalogCheckout,
} from "./catalog-git-config";
const exec = promisify(execFile);
export async function testCatalogConnection(config: ManagedCatalogConfig) {
try {
const result = await exec(
"git",
[
"ls-remote",
"--exit-code",
"--heads",
config.remote,
`refs/heads/${config.branch}`,
],
{
env: catalogGitEnvironment(config),
timeout: 30000,
maxBuffer: 1024 * 1024,
},
);
if (!result.stdout.trim()) throw new Error("Missing branch");
} catch {
throw new Error(
"Cannot read this Gitea repository and branch. Check the URL, username, token and server Git installation.",
);
}
}
export async function ensureManagedCatalogCheckout(
config: ManagedCatalogConfig,
) {
const checkout = managedCatalogCheckout(config);
try {
await fs.access(path.join(checkout, ".git"));
return checkout;
} catch {
/* first export */
}
await fs.mkdir(path.dirname(checkout), { recursive: true });
const temporary = `${checkout}.clone-${randomUUID()}`;
try {
await exec(
"git",
[
"clone",
"--single-branch",
"--branch",
config.branch,
"--",
config.remote,
temporary,
],
{
env: catalogGitEnvironment(config),
timeout: 600000,
maxBuffer: 4 * 1024 * 1024,
},
);
await fs.rename(temporary, checkout);
return checkout;
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
}
+78
View File
@@ -0,0 +1,78 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import {
catalogStateRoot,
readManagedCatalogConfig,
} from "./catalog-git-config";
import { CatalogExportQueue } from "./catalog-git-core";
export function catalogExportEnabled() {
const config = readManagedCatalogConfig();
return config
? config.enabled
: Boolean(process.env.CATALOG_GIT_CHECKOUT?.trim());
}
export function catalogExportQueue() {
return new CatalogExportQueue(catalogStateRoot());
}
export function isCatalogMutation(method: string, pathname: string) {
return (
["POST", "PUT", "PATCH", "DELETE"].includes(method) &&
(pathname.startsWith("/api/admin/import/") ||
pathname.startsWith("/api/admin/catalog/") ||
pathname.startsWith("/api/admin/furni/") ||
pathname.startsWith("/api/admin/furniture/"))
);
}
export async function catalogExportStatus() {
const q = catalogExportQueue();
if (!catalogExportEnabled()) return { enabled: false, pending: 0, active: 0 };
const entries = await q.entries();
let last: { commit?: string; finishedAt?: string; error?: string } = {};
try {
last = JSON.parse(
await fs.readFile(path.join(q.root, "status.json"), "utf8"),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
return {
enabled: true,
pending: entries.filter((f) => f.endsWith(".pending")).length,
active: entries.filter((f) => f.endsWith(".active")).length,
running: entries.includes("worker.lock"),
...last,
};
}
export async function withCatalogExport<T>(
operation: () => Promise<T>,
): Promise<T> {
if (!catalogExportEnabled()) return operation();
const finish = await beginCatalogExport();
try {
return await operation();
} finally {
await finish();
}
}
export async function scheduleCatalogExport() {
if (!readManagedCatalogConfig()?.enabled) return;
try {
const { after } = await import("next/server");
after(async () => {
const { runCatalogExport } = await import("./catalog-git-export");
await runCatalogExport();
});
} catch {
/* Standalone worker contexts use their scheduled retry. */
}
}
export async function beginCatalogExport() {
const finish = await catalogExportQueue().begin();
return async () => {
await finish();
await scheduleCatalogExport();
};
}
@@ -0,0 +1,161 @@
vi.mock("@/lib/services/figuredata", () => ({
getFigureDataPath: async () =>
path.join(await mocks.gamedata(), "custom/FigureData.json"),
}));
vi.mock("@/lib/services/figuremap", () => ({
getFigureMapPath: async () =>
path.join(await mocks.gamedata(), "custom/FigureMap.json"),
}));
vi.mock("@/lib/services/effectmap", () => ({
getEffectMapPath: async () =>
path.join(await mocks.gamedata(), "custom/EffectMap.json"),
}));
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
query: vi.fn(),
commit: vi.fn(),
rollback: vi.fn(),
release: vi.fn(),
gamedata: vi.fn(),
dirs: vi.fn(),
data: vi.fn(),
settings: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
db: { $client: { getConnection: async () => mocks } },
}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
getGamedataRoot: mocks.gamedata,
getFurniAssetDirs: mocks.dirs,
}));
vi.mock("@/lib/services/furni-data", () => ({
getFurnitureDataPath: mocks.data,
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: mocks.settings },
}));
import { createCatalogSnapshot } from "./catalog-git-snapshot";
describe("catalog snapshot", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.settings.mockResolvedValue("");
mocks.query.mockImplementation(async (sql: string) => {
if (sql === "SHOW TABLES")
return [
[
{ name: "items_base" },
{ name: "catalog_pages" },
{ name: "catalog_items" },
],
];
if (sql.startsWith("SHOW CREATE"))
return [
[
{
"Create Table":
"CREATE TABLE `test` (`id` int PRIMARY KEY, `name` text) ENGINE=InnoDB",
},
],
];
if (sql.startsWith("SHOW COLUMNS"))
return [
[
{ Field: "id", Extra: "" },
{ Field: "name", Extra: "" },
],
];
if (sql.startsWith("SELECT"))
return [[{ id: 1, name: "Valentine's chair" }]];
return [[]];
});
});
async function fixture() {
const root = await mkdtemp(path.join(os.tmpdir(), "catalog-snapshot-"));
for (const folder of [
"config",
"custom",
"icons",
"bundled/furniture",
"c_images/catalogue",
"output",
])
await mkdir(path.join(root, folder), { recursive: true });
await writeFile(
path.join(root, "config/FurnitureData.json"),
'{"roomitemtypes":{"furnitype":[]}}',
);
await writeFile(path.join(root, "config/FurnitureData_it.json"), "{}");
await writeFile(
path.join(root, "config/private-cache.json"),
'{"secret":"not exported"}',
);
await writeFile(path.join(root, "icons/chair_icon.png"), "image");
await writeFile(path.join(root, "bundled/furniture/chair.nitro"), "bundle");
await writeFile(
path.join(root, "c_images/catalogue/icon_1.png"),
"catalog icon",
);
await writeFile(
path.join(root, "custom/FigureData.json"),
'{"configured":true}',
);
await writeFile(path.join(root, "custom/FigureMap.json"), "{}");
await writeFile(path.join(root, "custom/EffectMap.json"), "{}");
mocks.gamedata.mockResolvedValue(root);
mocks.dirs.mockResolvedValue({
nitroDir: path.join(root, "bundled/furniture"),
iconDir: path.join(root, "icons"),
});
mocks.data.mockResolvedValue(path.join(root, "config/FurnitureData.json"));
return root;
}
it("includes bundles, both icon types, translations and SQL but excludes private caches", async () => {
const root = await fixture();
const files = await createCatalogSnapshot(path.join(root, "output"));
const targets = files.map((f) => f.target);
expect(targets).toContain("Gamedata/bundled/furniture/chair.nitro");
expect(targets).toContain("Gamedata/icons/chair_icon.png");
expect(targets).toContain("Gamedata/c_images/catalogue/icon_1.png");
expect(targets).toContain(
"catalogue version 2 ( Final (Dev)/langs furnidata/FurnitureData_it.json",
);
expect(targets.some((t) => t.includes("private-cache"))).toBe(false);
expect(targets.filter((t) => t.endsWith(".sql"))).toHaveLength(3);
const sqlFile = files.find((f) => f.target.endsWith("/items_base.sql"));
expect(sqlFile).toBeDefined();
expect(await readFile(sqlFile?.source ?? "", "utf8")).toContain(
"ON DUPLICATE KEY UPDATE",
);
const figure = files.find(
(f) => f.target === "Gamedata/config/FigureData.json",
);
expect(figure).toBeDefined();
expect(await readFile(figure?.source ?? "", "utf8")).toContain(
'"configured":true',
);
expect(targets).toContain("Gamedata/config/FigureMap.json");
expect(targets).toContain("Gamedata/config/EffectMap.json");
expect(mocks.commit).toHaveBeenCalledOnce();
expect(mocks.release).toHaveBeenCalledOnce();
});
it("rejects invalid JSON before publishing and releases failed SQL snapshots", async () => {
const root = await fixture();
mocks.query.mockRejectedValueOnce(new Error("DB unavailable"));
await expect(
createCatalogSnapshot(path.join(root, "output")),
).rejects.toThrow("DB unavailable");
expect(mocks.rollback).toHaveBeenCalledOnce();
expect(mocks.release).toHaveBeenCalledOnce();
await writeFile(path.join(root, "config/FurnitureData.json"), "{");
await expect(
createCatalogSnapshot(path.join(root, "output")),
).rejects.toThrow();
});
});
+216
View File
@@ -0,0 +1,216 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import type { RowDataPacket } from "mysql2";
import { db } from "@/lib/db";
import { getEffectMapPath } from "@/lib/services/effectmap";
import { getFigureDataPath } from "@/lib/services/figuredata";
import { getFigureMapPath } from "@/lib/services/figuremap";
import {
getFurniAssetDirs,
getGamedataRoot,
} from "@/lib/services/furni-asset-dirs";
import { getFurnitureDataPath } from "@/lib/services/furni-data";
import { siteSettings } from "@/lib/services/site-settings";
import {
CATALOG_LANGUAGE_ROOT,
CATALOG_SQL_ROOT,
type CatalogFile,
sqlValue,
} from "./catalog-git-core";
const CONFIG_FILES = [
"FurnitureData.json",
"EffectMap.json",
"ExternalTexts.json",
"FigureData.json",
"FigureMap.json",
"HabboAvatarActions.json",
"ProductData.json",
];
const TABLES = ["items_base", "catalog_pages", "catalog_items"] as const;
const identifier = (name: string) => `\`${name.replaceAll("`", "``")}\``;
export async function createCatalogSnapshot(
directory: string,
): Promise<CatalogFile[]> {
const sources = new Map<string, string>();
const addTree = async (root: string, target: string, extensions: RegExp) => {
async function walk(source: string, relative = "") {
const entries = await fs
.readdir(source, { withFileTypes: true })
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return [];
throw error;
});
for (const entry of entries) {
if (entry.isSymbolicLink())
throw new Error("Symlink in catalog source");
const local = path.join(source, entry.name);
const dest = relative ? `${relative}/${entry.name}` : entry.name;
if (entry.isDirectory()) await walk(local, dest);
else if (entry.isFile() && extensions.test(entry.name))
sources.set(`${target}/${dest}`, local);
}
}
await walk(root);
};
const gamedata = await getGamedataRoot();
const furni = await getFurniAssetDirs();
const furnitureData = await getFurnitureDataPath();
// Default files first; configured live locations take precedence.
await addTree(
path.join(process.cwd(), "public/swf/c_images"),
"Gamedata/c_images",
/\.(png|gif|webp|jpe?g)$/i,
);
await addTree(
path.join(process.cwd(), "public/nitro-assets/bundled"),
"Gamedata/bundled",
/\.nitro$/i,
);
if (gamedata) {
await addTree(
path.join(gamedata, "bundled"),
"Gamedata/bundled",
/\.nitro$/i,
);
await addTree(
path.join(gamedata, "icons"),
"Gamedata/icons",
/\.(png|gif|webp|jpe?g)$/i,
);
await addTree(
path.join(gamedata, "c_images"),
"Gamedata/c_images",
/\.(png|gif|webp|jpe?g)$/i,
);
}
await addTree(furni.nitroDir, "Gamedata/bundled/furniture", /\.nitro$/i);
await addTree(furni.iconDir, "Gamedata/icons", /\.(png|gif|webp|jpe?g)$/i);
for (const type of ["figure", "effect"]) {
const configured = await siteSettings.get(`${type}_nitro_dir`, "");
if (configured?.trim())
await addTree(configured, `Gamedata/bundled/${type}`, /\.nitro$/i);
}
// Pets currently write directly to the CMS directory.
await addTree(
path.join(process.cwd(), "public/nitro-assets/bundled/pet"),
"Gamedata/bundled/pet",
/\.nitro$/i,
);
const configRoot = gamedata
? path.join(gamedata, "config")
: path.dirname(furnitureData);
const configuredJson: Record<string, string> = {
FurnitureData: furnitureData,
FigureData: await getFigureDataPath(),
FigureMap: await getFigureMapPath(),
EffectMap: await getEffectMapPath(),
};
for (const name of CONFIG_FILES) {
const source =
configuredJson[name.replace(/\.json$/, "")] ??
path.join(configRoot, name);
try {
await fs.access(source);
sources.set(`Gamedata/config/${name}`, source);
} catch (error) {
if (
name === "FurnitureData.json" ||
(error as NodeJS.ErrnoException).code !== "ENOENT"
)
throw error;
}
}
for (const name of await fs.readdir(path.dirname(furnitureData))) {
if (/^FurnitureData_[a-z]{2}\.json$/.test(name)) {
sources.set(
`${CATALOG_LANGUAGE_ROOT}/${name}`,
path.join(path.dirname(furnitureData), name),
);
}
}
const files: CatalogFile[] = [];
const fingerprints = new Map<string, string>();
const fingerprint = async (source: string) => {
const stat = await fs.lstat(source);
if (!stat.isFile() || stat.isSymbolicLink())
throw new Error("Invalid catalog source");
return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
};
for (const [target, source] of sources) {
fingerprints.set(source, await fingerprint(source));
const copy = path.join(directory, String(files.length));
await fs.copyFile(source, copy);
if (target.endsWith(".json")) JSON.parse(await fs.readFile(copy, "utf8"));
files.push({ source: copy, target });
}
const connection = await db.$client.getConnection();
try {
await connection.query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");
await connection.query(
"START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY",
);
const [available] = await connection.query<RowDataPacket[]>("SHOW TABLES");
const tableNames = new Set(
available.map((row) => String(Object.values(row)[0])),
);
const tables = [
...TABLES,
...["catalog_pages_bc", "catalog_items_bc"].filter((name) =>
tableNames.has(name),
),
];
for (const table of tables) {
const [schemaRows] = await connection.query<RowDataPacket[]>(
`SHOW CREATE TABLE ${identifier(table)}`,
);
const definition = String(schemaRows[0]["Create Table"]);
if (!/ENGINE=InnoDB\b/i.test(definition))
throw new Error("Catalog SQL snapshots require InnoDB tables");
const [columns] = await connection.query<RowDataPacket[]>(
`SHOW COLUMNS FROM ${identifier(table)}`,
);
const names = columns
.filter(
(c) => !/\b(?:VIRTUAL|STORED) GENERATED\b/.test(String(c.Extra)),
)
.map((c) => String(c.Field));
const quoted = names.map(identifier).join(", ");
const [rows] = await connection.query<RowDataPacket[]>(
`SELECT ${quoted} FROM ${identifier(table)} ORDER BY id`,
);
const source = path.join(directory, `${table}.sql`);
const output = await fs.open(source, "wx");
try {
await output.writeFile(
`-- Catalog Studio snapshot. Existing rows are updated; absent rows are not deleted.\nSET NAMES utf8mb4;\n${definition.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS")};\nSTART TRANSACTION;\n`,
);
const update = names
.filter((n) => n !== "id")
.map((n) => `${identifier(n)}=VALUES(${identifier(n)})`)
.join(", ");
for (const row of rows) {
await output.writeFile(
`INSERT INTO ${identifier(table)} (${quoted}) VALUES (${names.map((n) => sqlValue(row[n])).join(", ")}) ON DUPLICATE KEY UPDATE ${update};\n`,
);
}
await output.writeFile("COMMIT;\n");
} finally {
await output.close();
}
files.push({ source, target: `${CATALOG_SQL_ROOT}/${table}.sql` });
}
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
for (const [source, stamp] of fingerprints) {
if ((await fingerprint(source)) !== stamp)
throw new Error("Assets changed during export; retry required");
}
return files;
}
+11
View File
@@ -304,3 +304,14 @@ describe("fetchSourceFurnidata", () => {
).rejects.toThrow(/Cloudflare challenge/);
});
});
it("preserves numeric source revisions for asset downloads", () => {
const [entry] = parseFurnidata({
roomitemtypes: {
furnitype: [
{ id: 16940, classname: "recycler_kintsugiB", revision: "73706" },
],
},
});
expect(entry.revision).toBe(73706);
});
+20 -8
View File
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
import { eq, inArray, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { normalizeClassname } from "@/lib/furni/classname";
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
@@ -68,6 +69,10 @@ function toSourceFurni(
return {
...raw,
id: Number(raw.id ?? 0),
revision:
Number.isSafeInteger(Number(raw.revision)) && Number(raw.revision) > 0
? Number(raw.revision)
: 0,
classname: String(raw.classname ?? ""),
name: String(raw.name ?? raw.classname ?? ""),
description: String(raw.description ?? ""),
@@ -521,21 +526,24 @@ export async function getCloneList(params: {
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(inArray(ItemsBase.itemName, allNames));
for (const r of rows) present.add(r.itemName);
for (const r of rows) present.add(normalizeClassname(r.itemName));
}
const filtered =
filter === "missing"
? searched.filter((e) => !present.has(e.classname))
? searched.filter((e) => !present.has(normalizeClassname(e.classname)))
: filter === "present"
? searched.filter((e) => present.has(e.classname))
? searched.filter((e) => present.has(normalizeClassname(e.classname)))
: searched;
const total = filtered.length;
const start = (Math.max(page, 1) - 1) * perPage;
const slice = filtered.slice(start, start + perPage);
return {
items: slice.map((e) => ({ ...e, present: present.has(e.classname) })),
items: slice.map((e) => ({
...e,
present: present.has(normalizeClassname(e.classname)),
})),
meta: { page: Math.max(page, 1), perPage, total },
};
}
@@ -551,9 +559,11 @@ export async function getCloneStats(
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(inArray(ItemsBase.itemName, classnames));
for (const r of rows) have.add(r.itemName);
for (const r of rows) have.add(normalizeClassname(r.itemName));
}
const present = all.filter((e) => have.has(e.classname)).length;
const present = all.filter((e) =>
have.has(normalizeClassname(e.classname)),
).length;
return { total: all.length, present, clonable: all.length - present };
}
@@ -572,7 +582,9 @@ export async function getClonableClassnames(
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(inArray(ItemsBase.itemName, classnames));
for (const r of rows) have.add(r.itemName);
for (const r of rows) have.add(normalizeClassname(r.itemName));
}
return all.filter((e) => !have.has(e.classname)).map((e) => e.classname);
return all
.filter((e) => !have.has(normalizeClassname(e.classname)))
.map((e) => e.classname);
}
+12 -1
View File
@@ -47,6 +47,7 @@ describe("clone inventory status", () => {
classname: "modern_chair",
name: "Modern chair",
description: "A modern chair",
revision: 73706,
itemType: "s",
category: "chair",
present: true,
@@ -70,7 +71,16 @@ describe("clone inventory status", () => {
dependencies.existsSync.mockReturnValue(true);
});
it("checks catalog presence with the allocated local ID, not the remote source ID", async () => {
it("checks normalized local IDs, asset paths and revisions for source furniture", async () => {
dependencies.localItems.mockResolvedValue([
{ id: 42, itemName: "Modern_Chair" },
]);
dependencies.readFurniData.mockResolvedValue({
roomitemtypes: { furnitype: [{ classname: "Modern_Chair" }] },
});
dependencies.existsSync.mockImplementation((assetPath: string) =>
assetPath.endsWith("Modern_Chair.nitro"),
);
const result = await getCloneInventoryStatus({
source: {
id: "hotel-nl",
@@ -89,6 +99,7 @@ describe("clone inventory status", () => {
expect(result.items[0]).toMatchObject({
id: 900,
classname: "modern_chair",
revision: 73706,
hasCatalogEntry: true,
hasFurnidata: true,
nitroExists: true,
+28 -11
View File
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
import path from "node:path";
import { inArray, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import { indexLocalFurniture } from "@/lib/furni/local-presence";
import { getCloneList, getCloneStats } from "@/lib/services/clone-import";
import type { CloneSource } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -13,6 +15,7 @@ export interface CloneInventoryStatusItem {
readonly name: string;
readonly itemType: "s" | "i";
readonly description: string;
readonly revision: number;
readonly category: string;
readonly present: boolean;
readonly nitroExists?: boolean;
@@ -50,17 +53,20 @@ export async function getCloneInventoryStatus(params: {
const importedNames = listResult.items
.filter((item) => item.present)
.map((item) => item.classname);
const localIdsByClassname = new Map<string, number>();
let localIndex = indexLocalFurniture<{ id: number; itemName: string }>([]);
const catalogItemIds = new Set<number>();
if (importedNames.length > 0) {
try {
const localItems = await db
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(inArray(ItemsBase.itemName, importedNames));
for (const item of localItems) {
if (item.itemName) localIdsByClassname.set(item.itemName, item.id);
}
.where(
inArray(
sql`LOWER(TRIM(${ItemsBase.itemName}))`,
importedNames.map(normalizeClassname),
),
);
localIndex = indexLocalFurniture(localItems);
const catalogRows = await db.execute(
sql`SELECT item_ids FROM catalog_items`,
);
@@ -89,7 +95,8 @@ export async function getCloneInventoryStatus(params: {
...(furnidata.wallitemtypes?.furnitype ?? []),
]
.map((item) => item.classname)
.filter((classname): classname is string => Boolean(classname)),
.filter((classname): classname is string => Boolean(classname))
.map(normalizeClassname),
);
} catch {
partialDependencies.add("filesystem");
@@ -111,23 +118,33 @@ export async function getCloneInventoryStatus(params: {
meta: listResult.meta,
partialDependencies: [...partialDependencies],
items: listResult.items.map((item) => {
const localId = localIdsByClassname.get(item.classname);
const classnameKey = normalizeClassname(item.classname);
const localRows = localIndex.get(classnameKey) ?? [];
return {
id: item.id,
classname: item.classname,
name: item.name,
description: item.description,
revision: Number(item.revision) || 0,
itemType: item.itemType,
category: String(item.category ?? ""),
present: item.present,
...(item.present && nitroExists
? { nitroExists: nitroExists(item.classname) }
? {
nitroExists:
nitroExists(item.classname) ||
localRows.some((row) => nitroExists?.(row.itemName)),
}
: {}),
...(item.present && furnidataClassnames
? { hasFurnidata: furnidataClassnames.has(item.classname) }
? { hasFurnidata: furnidataClassnames.has(classnameKey) }
: {}),
...(item.present && localId !== undefined
? { hasCatalogEntry: catalogItemIds.has(localId) }
...(item.present && localRows.length > 0
? {
hasCatalogEntry: localRows.some((row) =>
catalogItemIds.has(row.id),
),
}
: {}),
};
}),
@@ -0,0 +1,216 @@
// @vitest-environment node
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
official: vi.fn(),
download: vi.fn(),
execute: vi.fn(),
select: vi.fn(),
stat: vi.fn(),
conflict: vi.fn(),
build: vi.fn(),
append: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
db: { select: mocks.select, execute: mocks.execute },
ItemsBase: {
id: "id",
itemName: "item_name",
spriteId: "sprite_id",
type: "type",
},
CatalogPages: {},
}));
vi.mock("node:fs", () => ({
existsSync: () => true,
promises: {
stat: mocks.stat,
readFile: vi.fn().mockResolvedValue(Buffer.alloc(256)),
},
}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
DEFAULT_FURNI_NITRO_DIR: "/nitro",
getFurniAssetWriteTargets: async () => ({
swfDir: "/swf",
nitroDir: "/nitro",
iconDir: "/icons",
mirrorDirs: [],
}),
}));
vi.mock("@/lib/services/furni-data", () => ({
findFurniDataIdConflict: mocks.conflict,
buildFurniEntry: mocks.build,
appendFurniEntry: mocks.append,
}));
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
lookupOfficialHabboFurni: mocks.official,
}));
vi.mock("@/lib/services/habbo-gamedata-hotel", () => ({
getHabboGamedataHotel: async () => "it",
}));
vi.mock("@/lib/services/import/core/download", () => ({
downloadFile: vi.fn(),
tryDownloadCandidates: mocks.download,
}));
vi.mock("@/lib/services/swf-to-nitro", () => ({
parseNitroBundle: () => ({ json: {} }),
extractIconFromSwfAsync: async () => null,
}));
import { importSingleFurni } from "./furni-import";
const input = {
id: 42,
classname: "recycler_scrapbookA",
name: "Scrapbook",
description: "Book",
type: "flooritem",
revision: 1,
category: "unknown",
repairExisting: true,
};
const dialect = new MySqlDialect();
function queries() {
return mocks.execute.mock.calls.map(([query]) => dialect.sqlToQuery(query));
}
describe("completion of existing furniture", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.official.mockResolvedValue(null);
mocks.download.mockResolvedValue(false);
const rows = [
[{ id: 2000037257, spriteId: 9001, type: "s" }],
[{ itemName: input.classname }],
];
mocks.select.mockImplementation(() => ({
from: () => ({ where: () => ({ limit: async () => rows.shift() }) }),
}));
mocks.stat.mockResolvedValue({ size: 256 });
mocks.conflict.mockResolvedValue(null);
mocks.execute.mockResolvedValue([[{ id: 123, page_id: "15" }], []]);
mocks.build.mockImplementation((value) => value);
});
it("reuses local item and sprite IDs without rewriting SQL settings or offers", async () => {
const result = await importSingleFurni(input);
expect(result.ok).toBe(true);
expect(result.itemId).toBe(2000037257);
expect(mocks.conflict).toHaveBeenCalledWith(9001, input.classname);
expect(mocks.build).toHaveBeenCalledWith(
expect.objectContaining({ id: 9001, spriteId: 9001 }),
);
expect(mocks.append).toHaveBeenCalled();
expect(
queries().every((query) => query.sql.trim().startsWith("SELECT")),
).toBe(true);
expect(queries()[0].params).toContain("2000037257");
});
it("honors cancellation at the relocated database write boundary", async () => {
const controller = new AbortController();
await expect(
importSingleFurni({
...input,
signal: controller.signal,
onProgress: (stage) => {
if (stage === "writing_db") controller.abort();
},
}),
).rejects.toMatchObject({ name: "AbortError" });
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
it("never deletes existing SQL when the nitro cannot be recovered", async () => {
mocks.stat.mockRejectedValue(new Error("missing"));
const result = await importSingleFurni(input);
expect(result.ok).toBe(false);
expect(result.error).toContain("preserved");
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
it("rejects an occupied furnidata sprite before any mutation", async () => {
mocks.conflict.mockResolvedValue("other_furniture");
const result = await importSingleFurni(input);
expect(result.ok).toBe(false);
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
});
it("imports recycler_kintsugiB with an allocated ID throughout SQL and furnidata", async () => {
vi.clearAllMocks();
const selections = [[], [{ itemName: "recycler_kintsugi" }]];
mocks.select.mockImplementation(() => ({
from: () => ({ where: () => ({ limit: async () => selections.shift() }) }),
}));
mocks.conflict.mockResolvedValue(null);
mocks.stat.mockResolvedValue({ size: 256 });
mocks.build.mockImplementation((value) => value);
mocks.execute.mockImplementation(async (query) => {
const { sql, params } = dialect.sqlToQuery(query);
if (sql.includes("MAX(")) return [[{ maximum: 2000037257 }], []];
if (sql.includes("SELECT item_name"))
return [
params[0] === 16940 ? [{ item_name: "recycler_kintsugi" }] : [],
[],
];
if (sql.includes("SELECT id, page_id"))
return [[{ id: 123, page_id: "15" }], []];
return [[], []];
});
mocks.official.mockResolvedValue({ revision: 73706, xdim: 1, ydim: 1 });
const signal = new AbortController().signal;
const result = await importSingleFurni({
...input,
signal,
revision: 0,
id: 16940,
classname: "recycler_kintsugiB",
});
expect(result.ok).toBe(true);
expect(result.itemId).toBe(2000037258);
expect(mocks.download).toHaveBeenCalledWith(
expect.arrayContaining([
"https://images.habbo.com/dcr/hof_furni/73706/recycler_kintsugiB.swf",
]),
expect.any(String),
"swf",
expect.any(Function),
signal,
);
expect(mocks.build).toHaveBeenCalledWith(
expect.objectContaining({
id: 2000037258,
spriteId: 2000037258,
classname: "recycler_kintsugiB",
}),
);
expect(
queries()
.find((q) => q.sql.includes("INSERT INTO items_base"))
?.params.slice(0, 2),
).toEqual([2000037258, 2000037258]);
expect(
queries().find((q) => q.sql.includes("SELECT id, page_id"))?.params,
).toContain("2000037258");
expect(result.warnings.join(" ")).toContain("16940");
});
it("does not create or delete SQL rows when a new furniture asset is unavailable", async () => {
vi.clearAllMocks();
mocks.official.mockResolvedValue(null);
mocks.download.mockResolvedValue(false);
mocks.select.mockImplementation(() => ({
from: () => ({ where: () => ({ limit: async () => [] }) }),
}));
mocks.stat.mockRejectedValue(new Error("missing"));
mocks.conflict.mockResolvedValue(null);
const result = await importSingleFurni({
...input,
classname: "missing_new_asset",
});
expect(result.ok).toBe(false);
expect(result.error).toContain("Import not started");
expect(mocks.execute).not.toHaveBeenCalled();
expect(mocks.append).not.toHaveBeenCalled();
});
+107 -57
View File
@@ -37,6 +37,7 @@ import {
} from "@/lib/services/swf-to-nitro";
import { getRuntimePath } from "@/lib/utils/runtime-path";
import type { ImportSingleResult } from "@/types/furni";
import { reserveFurnitureId } from "./furniture-id-reservation";
// Re-export the download helpers now owned by the shared import core.
// Existing furni callers keep importing them from here.
@@ -515,6 +516,7 @@ export async function importSingleFurni(params: {
category: string;
skipFurniDataWrite?: boolean;
updateExisting?: boolean;
repairExisting?: boolean;
onProgress?: (status: string) => void;
signal?: AbortSignal;
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
@@ -533,6 +535,7 @@ export async function importSingleFurni(params: {
category,
skipFurniDataWrite,
updateExisting,
repairExisting,
onProgress,
signal,
sourceSwfBaseUrl,
@@ -544,18 +547,22 @@ export async function importSingleFurni(params: {
// Check if already exists by classname OR by spriteId (primary key collision).
const [existsByName] = await db
.select({ id: ItemsBase.id })
.select({
id: ItemsBase.id,
spriteId: ItemsBase.spriteId,
type: ItemsBase.type,
})
.from(ItemsBase)
.where(eq(ItemsBase.itemName, classname))
.limit(1);
if (existsByName && !updateExisting) {
if (existsByName && !updateExisting && !repairExisting) {
return {
ok: false,
warnings,
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
};
}
if (existsByName && existsByName.id !== originalId) {
if (existsByName && existsByName.id !== originalId && !repairExisting) {
// The classname already occupies a different spriteId. Reassigning the
// id would orphan every reference (catalog item_ids, player items,
// furnidata), so this can never be an "update".
@@ -565,12 +572,16 @@ export async function importSingleFurni(params: {
error: `Item "${classname}" already exists with id ${existsByName.id} — cannot reassign to spriteId ${originalId}`,
};
}
let itemId = repairExisting && existsByName ? existsByName.id : originalId;
let spriteId =
repairExisting && existsByName ? existsByName.spriteId : originalId;
const preservingExisting = !!(repairExisting && existsByName);
const [existsById] = await db
.select({ itemName: ItemsBase.itemName })
.from(ItemsBase)
.where(eq(ItemsBase.id, originalId))
.where(eq(ItemsBase.id, itemId))
.limit(1);
if (existsById && (!existsByName || existsById.itemName !== classname)) {
if (existsByName && existsById && existsById.itemName !== classname) {
// The spriteId is owned by a different classname. Even with
// updateExisting this is a genuine conflict, not an update.
return {
@@ -583,17 +594,20 @@ export async function importSingleFurni(params: {
const isUpdate = !!existsByName;
// ── Check if spriteId already used in FurnitureData.json ──
const takenBy = await findFurniDataIdConflict(originalId, classname);
if (takenBy && !updateExisting) {
const takenBy = await findFurniDataIdConflict(spriteId, classname);
if (takenBy && isUpdate) {
return {
ok: false,
warnings,
error: `spriteId ${originalId} already used by "${takenBy}" in FurnitureData.json`,
error: `spriteId ${spriteId} already used by "${takenBy}" in FurnitureData.json`,
};
}
const itemType = type === "wallitem" ? "i" : "s";
const spriteId = originalId;
const itemType = preservingExisting
? existsByName.type
: type === "wallitem"
? "i"
: "s";
// ── Cross-reference with configured official Habbo furnidata ──────
let officialHabbo: OfficialHabboFurniEntry | null = null;
@@ -613,34 +627,8 @@ export async function importSingleFurni(params: {
const bestRevision = revision || officialHabbo?.revision || 0;
const bestCategory = category || officialHabbo?.category || "unknown";
// ── 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 —
// those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Only refresh the columns this import manages.
await db.execute(sql`
UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType}
WHERE id = ${spriteId}
`);
} else {
await db.execute(sql`
INSERT INTO items_base (id, sprite_id, public_name, item_name, type)
VALUES (${spriteId}, ${spriteId}, ${bestName}, ${classname}, ${itemType})
`);
}
} catch (err) {
return {
ok: false,
warnings,
error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`,
};
}
// ── Derive file-safe names ────────────────────────────────────────
const rev = revision || 0;
const rev = bestRevision;
const assetTargets = await getFurniAssetWriteTargets();
const { swfDir, iconDir, nitroDir } = assetTargets;
const starIdx = classname.indexOf("*");
@@ -713,9 +701,31 @@ export async function importSingleFurni(params: {
// Download icon + SWF concurrently (independent assets). Each tries its
// candidate URLs in order until one succeeds.
let iconFailure = "",
swfFailure = "";
const [iconOkResult, swfOk] = await Promise.all([
tryDownloadCandidates(iconUrls, iconPath, "png", signal),
tryDownloadCandidates(swfUrls, swfPath, "swf", signal),
preservingExisting && existsSync(iconPath)
? Promise.resolve(true)
: tryDownloadCandidates(
iconUrls,
iconPath,
"png",
(detail) => {
iconFailure = detail;
},
signal,
),
preservingExisting && existsSync(swfPath)
? Promise.resolve(true)
: tryDownloadCandidates(
swfUrls,
swfPath,
"swf",
(detail) => {
swfFailure = detail;
},
signal,
),
]);
let iconOk = iconOkResult;
@@ -729,7 +739,7 @@ export async function importSingleFurni(params: {
) {
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
const dl = await downloadFile(nitroUrl, nitroPath, {
validate: "png",
validate: "nitro",
signal,
});
if (dl.ok) nitroDownloadOk = true;
@@ -753,10 +763,13 @@ export async function importSingleFurni(params: {
warnings.push("Icon download failed and SWF icon extraction also failed");
}
} else if (!iconOk) {
warnings.push("Icon image download failed");
warnings.push(
`Icon image download failed: ${iconFailure || "unknown cause"}`,
);
}
if (!swfOk && !nitroDownloadOk) warnings.push("SWF download failed");
if (!swfOk && !nitroDownloadOk)
warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`);
// ── Convert SWF to Nitro ──────────────────────────────────────────
onProgress?.("converting");
@@ -801,7 +814,7 @@ export async function importSingleFurni(params: {
.catch(() => null);
const nitroValid = nitroStat && nitroStat.size >= 128;
if (!nitroValid && !conversionResult) {
if (!nitroValid) {
if (nitroStat) {
warnings.push(
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
@@ -812,17 +825,54 @@ export async function importSingleFurni(params: {
/* ignore */
}
}
try {
await db.execute(
sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`,
);
} catch {
/* best effort cleanup */
}
return {
ok: false,
warnings,
error: "Import rolled back: no .nitro file could be created",
error: `${isUpdate ? "No .nitro file could be created; existing furniture was preserved" : "Import not started: no .nitro file could be created"} (${classname}, revision ${rev}). ${warnings
.filter((warning) => /conversion|download|SWF/.test(warning))
.slice(-3)
.join(". ")}`,
};
}
// ── Insert into DB with enriched data ──────────────────────────────
onProgress?.("writing_db");
signal?.throwIfAborted();
try {
if (preservingExisting) {
warnings.push(
`Existing furniture #${itemId}: SQL settings and catalog offers preserved`,
);
} else if (isUpdate) {
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
// those metadata fields are persisted in FurnitureData.json (see entry below),
// not in the DB. Only refresh the columns this import manages.
await db.execute(sql`
UPDATE items_base SET sprite_id = ${spriteId}, public_name = ${bestName}, item_name = ${classname}, type = ${itemType}
WHERE id = ${spriteId}
`);
} else {
itemId = await reserveFurnitureId(
originalId,
classname,
async (allocatedId) => {
await db.execute(
sql`INSERT INTO items_base (id, sprite_id, public_name, item_name, type) VALUES (${allocatedId}, ${allocatedId}, ${bestName}, ${classname}, ${itemType})`,
);
},
);
spriteId = itemId;
if (spriteId !== originalId)
warnings.push(
`Source ID ${originalId} is occupied; imported with local ID ${spriteId}`,
);
}
} catch (err) {
return {
ok: false,
warnings,
error: `DB ${isUpdate ? "update" : "insert"} failed: ${(err as Error).message}`,
};
}
@@ -938,7 +988,7 @@ export async function importSingleFurni(params: {
// Update items_base with ALL enriched data
try {
const update = buildItemsBaseEnrichmentUpdate({
id: spriteId,
id: itemId,
dims,
cansiton: autoInteraction.canSit,
canlayon: autoInteraction.canLay,
@@ -947,7 +997,7 @@ export async function importSingleFurni(params: {
interactionType,
interactionModesCount,
});
await db.execute(update);
if (!preservingExisting) await db.execute(update);
} catch (err) {
warnings.push(
`items_base dimension update failed: ${(err as Error).message}`,
@@ -986,16 +1036,16 @@ export async function importSingleFurni(params: {
let catalogItemId: number | null = null;
let catalogUpdated = false;
try {
const itemIdStr = String(spriteId);
const itemIdStr = String(itemId);
// Check if this item already exists anywhere in the catalog
const [existingCatalog] = (await db.execute(sql`
SELECT id, page_id FROM catalog_items WHERE item_ids = ${itemIdStr} LIMIT 1
SELECT id, page_id FROM catalog_items WHERE FIND_IN_SET(${itemIdStr}, REPLACE(item_ids, ';', ',')) > 0 LIMIT 1
`)) as unknown as [Array<{ id: number; page_id: string }>, unknown];
if (existingCatalog.length > 0) {
catalogItemId = existingCatalog[0].id;
if (updateExisting) {
if (updateExisting && !preservingExisting) {
// Update existing catalog entry: price, page, etc.
const categoryPageId = await getOrCreateCategoryPage(
classname,
@@ -1039,7 +1089,7 @@ export async function importSingleFurni(params: {
return {
ok: true,
itemId: spriteId,
itemId,
catalogItemId,
catalogUpdated,
dimensions: dims,
@@ -0,0 +1,80 @@
// @vitest-environment node
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ execute: vi.fn(), conflict: vi.fn() }));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
vi.mock("./furni-data", () => ({ findFurniDataIdConflict: mocks.conflict }));
import { reserveFurnitureId } from "./furniture-id-reservation";
const dialect = new MySqlDialect();
let occupied: Set<number>;
beforeEach(() => {
occupied = new Set([16940]);
mocks.conflict.mockReset().mockResolvedValue(null);
mocks.execute.mockReset().mockImplementation(async (query) => {
const { sql, params } = dialect.sqlToQuery(query);
return sql.includes("MAX(")
? [[{ maximum: Math.max(...occupied) }], []]
: [
occupied.has(Number(params[0]))
? [{ item_name: "recycler_kintsugi" }]
: [],
[],
];
});
});
describe("furniture ID reservation", () => {
it("remaps a source ID already owned by another furniture", async () => {
const insert = vi.fn();
expect(await reserveFurnitureId(16940, "recycler_kintsugiB", insert)).toBe(
16941,
);
expect(insert).toHaveBeenCalledWith(16941);
});
it("checks furnidata-only collisions too", async () => {
mocks.conflict.mockImplementation(async (id) =>
id === 16941 ? "other" : null,
);
expect(await reserveFurnitureId(16940, "new", vi.fn())).toBe(16942);
});
it("reserves distinct IDs for concurrent imports", async () => {
const insert = async (id: number) => {
await Promise.resolve();
occupied.add(id);
};
expect(
await Promise.all([
reserveFurnitureId(16940, "first", insert),
reserveFurnitureId(16940, "second", insert),
]),
).toEqual([16941, 16942]);
});
it("retries competing primary-key inserts without overwriting the owner", async () => {
const insert = vi
.fn()
.mockImplementationOnce(async () => {
occupied.add(16941);
throw Object.assign(new Error("duplicate"), { code: "ER_DUP_ENTRY" });
})
.mockResolvedValue(undefined);
expect(await reserveFurnitureId(16940, "new", insert)).toBe(16942);
});
it("propagates non-collision errors and releases the queue", async () => {
await expect(
reserveFurnitureId(16940, "new", async () => {
throw new Error("DB unavailable");
}),
).rejects.toThrow("DB unavailable");
expect(await reserveFurnitureId(16940, "next", vi.fn())).toBe(16941);
});
it("fails safely at the client integer limit", async () => {
occupied.add(2147483647);
const insert = vi.fn();
await expect(reserveFurnitureId(16940, "new", insert)).rejects.toThrow(
"No valid furniture ID",
);
expect(insert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,56 @@
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { findFurniDataIdConflict } from "./furni-data";
let reservation: Promise<void> = Promise.resolve();
/** Reserve both SQL and client identifiers by inserting while holding the local queue.
* The primary key handles competing processes; only duplicate-key failures retry.
*/
export async function reserveFurnitureId(
preferred: number,
classname: string,
insert: (id: number) => Promise<void>,
): Promise<number> {
let release!: () => void;
const previous = reservation;
reservation = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
let candidate = preferred;
for (let attempt = 0; attempt < 100; attempt++) {
if (
!Number.isSafeInteger(candidate) ||
candidate < 1 ||
candidate > 2147483647
)
throw new Error("No valid furniture ID is available");
const [owners] = (await db.execute(
sql`SELECT item_name FROM items_base WHERE id = ${candidate} OR sprite_id = ${candidate} LIMIT 1`,
)) as unknown as [Array<{ item_name: string }>, unknown];
const occupied =
owners.length > 0 ||
!!(await findFurniDataIdConflict(candidate, classname));
if (!occupied) {
try {
await insert(candidate);
return candidate;
} catch (error) {
const cause =
(error as { cause?: { code?: string; errno?: number } }).cause ??
(error as { code?: string; errno?: number });
if (cause.code !== "ER_DUP_ENTRY" && cause.errno !== 1062)
throw error;
}
}
const [rows] = (await db.execute(
sql`SELECT GREATEST(COALESCE(MAX(id), 0), COALESCE(MAX(sprite_id), 0)) AS maximum FROM items_base`,
)) as unknown as [Array<{ maximum: number | string }>, unknown];
candidate = Math.max(candidate + 1, Number(rows[0]?.maximum ?? 0) + 1);
}
throw new Error("Cannot reserve a free furniture ID; retry the import");
} finally {
release();
}
}
@@ -0,0 +1,76 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SourceAssetItem } from "@/lib/furni/source-assets";
import { inspectSourceAssets, probeAsset } from "./furniture-source-assets";
const source: SourceAssetItem = {
id: 1,
classname: "h25_monkeybzn_pole",
name: "Jungle flags",
description: "Flags",
type: "flooritem",
revision: 73253,
category: "other",
};
const alternative = {
...source,
id: 2,
classname: "br_c25_monkeybzn_pole",
revision: 73257,
};
afterEach(() => vi.unstubAllGlobals());
describe("source asset preflight", () => {
it("keeps missing furniture unchanged even when a similar asset is available", async () => {
const fetchMock = vi.fn(
async (url: string) =>
new Response(null, { status: url.includes("/73257/") ? 200 : 404 }),
);
vi.stubGlobal("fetch", fetchMock);
const result = await inspectSourceAssets(source, [source, alternative]);
expect(result.state).toBe("missing");
expect(result.classname).toBe(source.classname);
expect(result.alternatives).toEqual([]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not describe blocked or unreachable servers as missing files", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 403 })),
);
const result = await inspectSourceAssets(
{ ...source, classname: "blocked_test" },
[alternative],
);
expect(result.state).toBe("unknown");
expect(result.alternatives).toEqual([]);
});
it("accepts an available configured Nitro source", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async (url: string) =>
new Response(null, { status: url.endsWith(".nitro") ? 200 : 404 }),
),
);
const result = await inspectSourceAssets(
{ ...source, classname: "custom_test" },
[],
{
id: "custom",
name: "Custom",
hotel: "",
furnidataUrl: "https://source.example/data.json",
nitroBaseUrl: "https://source.example/furniture",
iconBaseUrl: "",
},
);
expect(result.state).toBe("available");
});
it("caches a probe to avoid repeating CDN requests", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await probeAsset("https://example.test/cache.nitro");
await probeAsset("https://example.test/cache.nitro");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,87 @@
import type {
AssetAvailability,
SourceAssetCheck,
SourceAssetItem,
} from "@/lib/furni/source-assets";
import type { CloneSource } from "./clone-sources";
import { browserHeaders } from "./import/core/browser-headers";
const cache = new Map<string, { expires: number; state: AssetAvailability }>();
export async function probeAsset(url: string): Promise<AssetAvailability> {
const cached = cache.get(url);
if (cached && cached.expires > Date.now()) return cached.state;
let state: AssetAvailability = "unknown";
try {
const response = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(6000),
headers: browserHeaders(),
});
if (
response.ok &&
!response.headers.get("content-type")?.includes("text/html")
)
state = "available";
else if (response.status === 404 || response.status === 410)
state = "missing";
} catch {
/* A network problem is not proof the asset is absent. */
}
if (cache.size >= 2000) cache.clear();
cache.set(url, { expires: Date.now() + 60000, state });
return state;
}
export function furnitureAssetUrls(
item: SourceAssetItem,
source?: CloneSource | null,
) {
const base = encodeURIComponent(item.classname.split("*")[0]);
const urls: string[] = [];
if (source?.nitroBaseUrl)
urls.push(`${source.nitroBaseUrl.replace(/\/+$/, "")}/${base}.nitro`);
if (source?.sourceSwfBaseUrl) {
const root = source.sourceSwfBaseUrl.replace(/\/+$/, "");
urls.push(
`${root}/hof_furni/${item.revision}/${base}.swf`,
`${root}/hof_furni/${base}.swf`,
);
}
urls.push(
`https://images.habbo.com/dcr/hof_furni/${item.revision}/${base}.swf`,
);
return urls;
}
async function available(
item: SourceAssetItem,
source?: CloneSource | null,
): Promise<AssetAvailability> {
const states = await Promise.all(
furnitureAssetUrls(item, source).map(probeAsset),
);
return states.includes("available")
? "available"
: states.every((state) => state === "missing")
? "missing"
: "unknown";
}
export async function inspectSourceAssets(
item: SourceAssetItem,
official: SourceAssetItem[],
source?: CloneSource | null,
): Promise<SourceAssetCheck> {
const canonical = official.find(
(entry) => entry.classname === item.classname,
);
const effective = {
...item,
revision: item.revision || canonical?.revision || 0,
};
const state = await available(effective, source);
return {
classname: item.classname,
revision: effective.revision,
state,
alternatives: [],
};
}
+6 -3
View File
@@ -1,3 +1,4 @@
import { normalizeClassname } from "@/lib/furni/classname";
/**
* Furniture search & listing — powered by official Habbo furnidata.
*
@@ -37,7 +38,9 @@ export async function listFurniture(
const perPage = Math.min(options.perPage || 50, 100);
const page = options.page || 1;
const search = options.search?.toLowerCase().trim() || "";
const importedSet = options.importedSet;
const importedSet = options.importedSet
? new Set([...options.importedSet].map(normalizeClassname))
: undefined;
try {
const map = await getOfficialHabboFurnidata();
@@ -58,8 +61,8 @@ export async function listFurniture(
if (importedSet) {
// Non-imported first (id desc), then imported (id desc).
items.sort((a, b) => {
const aImp = importedSet.has(a.classname) ? 1 : 0;
const bImp = importedSet.has(b.classname) ? 1 : 0;
const aImp = importedSet.has(normalizeClassname(a.classname)) ? 1 : 0;
const bImp = importedSet.has(normalizeClassname(b.classname)) ? 1 : 0;
if (aImp !== bImp) return aImp - bImp;
return b.id - a.id;
});
@@ -0,0 +1,107 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const write = vi.hoisted(() => vi.fn());
vi.mock("node:fs", () => ({ promises: { writeFile: write } }));
import { downloadFile, tryDownloadCandidates } from "./download";
const fetchMock = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockReset();
write.mockReset().mockResolvedValue(undefined);
});
afterEach(() => vi.unstubAllGlobals());
const url = "https://cdn.example/73706/chair.swf";
describe("download failure diagnostics", () => {
it("preserves source failures while cancellation stops later candidates", async () => {
const controller = new AbortController();
fetchMock.mockResolvedValue(new Response("missing", { status: 404 }));
const failure = vi.fn(() => controller.abort());
await expect(
tryDownloadCandidates(
[url, "https://cdn.example/73707/chair.swf"],
"/assets/chair.swf",
"swf",
failure,
controller.signal,
),
).rejects.toMatchObject({ name: "AbortError" });
expect(failure).toHaveBeenCalledWith(
"cdn.example/73706/chair.swf: HTTP 404",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(write).not.toHaveBeenCalled();
});
it("propagates an aborted download without retrying or writing", async () => {
const controller = new AbortController();
fetchMock.mockImplementation(async () => {
controller.abort();
controller.signal.throwIfAborted();
});
await expect(
downloadFile(url, "/assets/chair.swf", { signal: controller.signal }),
).rejects.toMatchObject({ name: "AbortError" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(write).not.toHaveBeenCalled();
});
it("reports HTTP status without writing an error page", async () => {
fetchMock.mockResolvedValue(new Response("missing", { status: 404 }));
expect(
(
await downloadFile(url, "/assets/chair.swf", {
validate: "swf",
maxRetries: 0,
})
).error,
).toBe("HTTP 404");
expect(write).not.toHaveBeenCalled();
});
it("distinguishes a successful download from denied file permissions", async () => {
fetchMock.mockResolvedValue(new Response(Buffer.from("FWS12345")));
write.mockRejectedValue(
Object.assign(new Error("denied"), { code: "EACCES" }),
);
expect(
(
await downloadFile(url, "/assets/chair.swf", {
validate: "swf",
maxRetries: 0,
})
).error,
).toBe("Cannot save file (EACCES)");
});
it("reports DNS failures", async () => {
fetchMock.mockRejectedValue(
Object.assign(new Error("fetch failed"), {
cause: { code: "ENOTFOUND" },
}),
);
expect(
(await downloadFile(url, "/assets/chair.swf", { maxRetries: 0 })).error,
).toBe("Network failure (ENOTFOUND)");
});
it("reports a non-SWF response", async () => {
fetchMock.mockResolvedValue(new Response("<html>not an asset</html>"));
expect(
(
await downloadFile(url, "/assets/chair.swf", {
validate: "swf",
maxRetries: 0,
})
).error,
).toBe("Response is not a SWF file");
});
it("forwards the failing path without URL credentials or query secrets", async () => {
fetchMock.mockResolvedValue(new Response("missing", { status: 404 }));
const failure = vi.fn();
await tryDownloadCandidates(
["https://user:[email protected]/0/chair.swf?token=private"],
"/assets/chair.swf",
"swf",
failure,
);
expect(failure).toHaveBeenCalledWith("cdn.example/0/chair.swf: HTTP 404");
});
});
+17 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { validatePngBytes, validateSwfBytes } from "./download";
import { createNitroBundle } from "../../swf/nitro-builder";
import {
validateNitroBytes,
validatePngBytes,
validateSwfBytes,
} from "./download";
describe("import/core/download validators", () => {
it("accepts FWS/CWS/ZWS swf magic", () => {
@@ -22,3 +27,14 @@ describe("import/core/download validators", () => {
expect(validatePngBytes(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toBe(false);
});
});
it("validates nitro bundles separately from PNG files", () => {
const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const bundle = createNitroBundle({ name: "chair" }, png, "chair");
expect(validateNitroBytes(bundle)).toBe(true);
expect(validatePngBytes(bundle)).toBe(false);
expect(validateNitroBytes(png)).toBe(false);
expect(validateNitroBytes(Buffer.from("<html>Access denied</html>"))).toBe(
false,
);
});
+44 -8
View File
@@ -1,4 +1,5 @@
import { promises as fs } from "node:fs";
import { parseNitroBundle } from "../../swf/nitro-builder";
import { browserHeaders } from "./browser-headers";
@@ -18,16 +19,35 @@ export function validatePngBytes(buffer: Buffer): boolean {
);
}
export function validateNitroBytes(buffer: Buffer): boolean {
try {
const bundle = parseNitroBundle(buffer);
return (
!!bundle.json &&
typeof bundle.json === "object" &&
!Array.isArray(bundle.json) &&
validatePngBytes(bundle.png)
);
} catch {
return false;
}
}
export async function tryDownloadCandidates(
urls: string[],
destPath: string,
kind: "swf" | "png",
onFailure?: (detail: string) => void,
signal?: AbortSignal,
): Promise<boolean> {
for (const url of urls) {
signal?.throwIfAborted();
const res = await downloadFile(url, destPath, { validate: kind, signal });
if (res.ok) return true;
const source = new URL(url);
onFailure?.(
`${source.host}${source.pathname}: ${res.error ?? "download failed"}`,
);
}
return false;
}
@@ -37,14 +57,15 @@ export async function downloadFile(
destPath: string,
options?: {
maxRetries?: number;
validate?: "swf" | "png";
validate?: "swf" | "png" | "nitro";
signal?: AbortSignal;
},
): Promise<{ ok: boolean; size: number }> {
): Promise<{ ok: boolean; size: number; error?: string }> {
const maxRetries = options?.maxRetries ?? 3;
const baseDelay = 1000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
let stage: "download" | "write" = "download";
try {
options?.signal?.throwIfAborted();
if (attempt > 0) {
@@ -66,39 +87,54 @@ export async function downloadFile(
console.warn(
`[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`,
);
return { ok: false, size: 0 };
return { ok: false, size: 0, error: `HTTP ${res.status}` };
}
continue;
}
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 8) {
if (attempt === maxRetries) return { ok: false, size: 0 };
if (attempt === maxRetries)
return { ok: false, size: 0, error: "Empty or truncated response" };
continue;
}
if (options?.validate === "swf" && !validateSwfBytes(buffer)) {
if (attempt === maxRetries) {
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`);
return { ok: false, size: 0 };
return { ok: false, size: 0, error: "Response is not a SWF file" };
}
continue;
}
if (options?.validate === "png" && !validatePngBytes(buffer)) {
if (attempt === maxRetries) {
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`);
return { ok: false, size: 0 };
return { ok: false, size: 0, error: "Response is not a PNG file" };
}
continue;
}
if (options?.validate === "nitro" && !validateNitroBytes(buffer))
return { ok: false, size: 0, error: "Invalid Nitro bundle" };
stage = "write";
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
return { ok: true, size: buffer.length };
} catch (err) {
if (options?.signal?.aborted) throw err;
if (attempt === maxRetries) {
if (attempt === maxRetries || stage === "write") {
console.warn(
`[import-download] Download error ${url}:`,
(err as Error).message,
);
return { ok: false, size: 0 };
const failure = err as {
code?: string;
name?: string;
cause?: { code?: string };
};
const code =
failure.code ?? failure.cause?.code ?? failure.name ?? "unknown";
return {
ok: false,
size: 0,
error: `${stage === "write" ? "Cannot save file" : "Network failure"} (${code})`,
};
}
}
}
@@ -0,0 +1,73 @@
import { afterEach, expect, it, vi } from "vitest";
import { readImportResponse } from "@/lib/furni/import-response";
import { streamImportResponse } from "./single-response";
vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() }));
afterEach(() => vi.useRealTimers());
it("sends headers and heartbeats before a slow operation finishes", async () => {
vi.useFakeTimers();
let finish!: (response: Response) => void;
const operation = vi.fn(
() =>
new Promise<Response>((resolve) => {
finish = resolve;
}),
);
const response = streamImportResponse(operation);
expect(response.headers.get("X-Accel-Buffering")).toBe("no");
if (!response.body) throw Error("Missing stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
expect(decoder.decode((await reader.read()).value)).toContain('"started"');
await vi.advanceTimersByTimeAsync(10000);
expect(decoder.decode((await reader.read()).value)).toContain('"processing"');
finish(Response.json({ ok: true, id: 42 }));
expect(decoder.decode((await reader.read()).value)).toContain('"id":42');
expect((await reader.read()).done).toBe(true);
expect(operation).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("preserves failure status and the importer error", async () => {
const result = await readImportResponse(
streamImportResponse(async () =>
Response.json({ error: "Missing source asset" }, { status: 409 }),
),
);
expect(result).toEqual({
status: 409,
data: { error: "Missing source asset" },
});
});
it("delivers caught server failures instead of abruptly closing", async () => {
const result = await readImportResponse(
streamImportResponse(async () => {
throw Error("private database details");
}),
);
expect(result.status).toBe(500);
expect(result.data.error).toContain("Some changes may already be saved");
expect(result.data.error).not.toContain("private database");
});
it("reports proxy timeout status without rendering proxy HTML", async () => {
await expect(
readImportResponse(
new Response("<html>gateway error</html>", { status: 524 }),
),
).rejects.toThrow("HTTP 524");
});
it("does not treat a truncated stream as successful", async () => {
await expect(
readImportResponse(
new Response('data: {"type":"progress"}\n\n', {
headers: { "Content-Type": "text/event-stream" },
}),
),
).rejects.toThrow("before confirmation");
});
it("preserves JSON authentication failures", async () => {
expect(
await readImportResponse(
Response.json({ error: "Unauthorized" }, { status: 401 }),
),
).toEqual({ status: 401, data: { error: "Unauthorized" } });
});
@@ -0,0 +1,62 @@
import { logServerError } from "@/lib/server-log";
/** Return headers immediately and keep proxies alive while one import completes. */
export function streamImportResponse(
operation: () => Promise<Response>,
): Response {
const encoder = new TextEncoder();
let closed = false;
let timer: ReturnType<typeof setInterval> | undefined;
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (data: unknown) => {
if (!closed)
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
};
send({ type: "progress", stage: "started" });
timer = setInterval(
() => send({ type: "progress", stage: "processing" }),
10000,
);
void (async () => {
try {
const response = await operation();
send({
type: "result",
status: response.status,
data: await response.json(),
});
} catch (error) {
logServerError("furni.import_stream_failed", error);
send({
type: "result",
status: 500,
data: {
error:
"Import interrupted on the server. Some changes may already be saved. Check the furniture status before retrying.",
},
});
} finally {
clearInterval(timer);
if (!closed) {
closed = true;
controller.close();
}
}
})();
},
cancel() {
closed = true;
clearInterval(timer);
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}
@@ -0,0 +1,45 @@
// @vitest-environment node
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
convert: vi.fn(() => ({ bundle: Buffer.from("converted") })),
icon: vi.fn(() => Buffer.from("icon")),
}));
vi.mock("./index", () => ({
convertSwfToNitro: mocks.convert,
extractIconFromSwf: mocks.icon,
}));
vi.mock("node:worker_threads", () => ({
Worker: class extends EventEmitter {
postMessage() {
queueMicrotask(() =>
this.emit("error", new Error("worker module missing")),
);
}
},
}));
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
vi.clearAllMocks();
});
describe("conversion worker recovery", () => {
it("falls back when worker startup rejects the conversion job", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("VITEST", undefined);
const { convertSwfToNitroAsync } = await import("./conversion-pool");
const data = Buffer.from("SWF");
const result = await convertSwfToNitroAsync(data, "chair");
expect(result.bundle.toString()).toBe("converted");
expect(mocks.convert).toHaveBeenCalledWith(data, "chair");
});
it("recovers icon extraction from a worker startup error", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("VITEST", undefined);
const { extractIconFromSwfAsync } = await import("./conversion-pool");
expect(
(await extractIconFromSwfAsync(Buffer.from("SWF"), "chair"))?.toString(),
).toBe("icon");
});
});
+6 -4
View File
@@ -154,8 +154,10 @@ export async function convertSwfToNitroAsync(
buffer: Buffer,
classname: string,
): Promise<ConversionResult> {
const res = await run({ type: "convert", buffer, classname });
if (res.ok && res.result) return res.result;
const res = await run({ type: "convert", buffer, classname }).catch(
() => null,
);
if (res?.ok && res.result) return res.result;
// Worker failed for this item — fall back to the main thread.
return convertSwfToNitro(buffer, classname);
}
@@ -164,7 +166,7 @@ export async function extractIconFromSwfAsync(
buffer: Buffer,
classname: string,
): Promise<Buffer | null> {
const res = await run({ type: "icon", buffer, classname });
if (res.ok) return res.icon ?? null;
const res = await run({ type: "icon", buffer, classname }).catch(() => null);
if (res?.ok) return res.icon ?? null;
return extractIconFromSwf(buffer, classname);
}
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -873,6 +873,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Menü durchsuchen…",
"clearMenuSearch": "Menüsuche löschen",
"noMenuResults": "Keine passenden Seiten.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -1058,6 +1058,9 @@
"backToSite": "Back to site",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"housekeeping": "Permission archive",
"hotelManagementPanel": "Hotel management panel",
"rankLabel": "Rank {rank}",
+3
View File
@@ -873,6 +873,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Buscar en el menú…",
"clearMenuSearch": "Borrar búsqueda",
"noMenuResults": "No se encontraron páginas.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -873,6 +873,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Rechercher dans le menu…",
"clearMenuSearch": "Effacer la recherche",
"noMenuResults": "Aucune page trouvée.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -1016,6 +1016,9 @@
"backToSite": "Torna al sito",
"expandAll": "Apri tutte",
"collapseAll": "Chiudi tutte",
"searchMenu": "Cerca nel menu…",
"clearMenuSearch": "Cancella ricerca menu",
"noMenuResults": "Nessuna pagina trovata.",
"hotelManagementPanel": "Pannello di gestione hotel",
"rankLabel": "Grado {rank}",
"hubNewsMedia": "Notizie e media",
+3
View File
@@ -1047,6 +1047,9 @@
"backToSite": "Terug naar site",
"expandAll": "Alles openen",
"collapseAll": "Alles sluiten",
"searchMenu": "Zoeken in menu…",
"clearMenuSearch": "Menuzoekopdracht wissen",
"noMenuResults": "Geen pagina’s gevonden.",
"housekeeping": "Permissie-archief",
"hotelManagementPanel": "Hotelbeheerpaneel",
"rankLabel": "Rang {rank}",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -901,6 +901,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",
+3
View File
@@ -903,6 +903,9 @@
"marketplace": "Marketplace",
"expandAll": "Expand all",
"collapseAll": "Collapse all",
"searchMenu": "Search menu…",
"clearMenuSearch": "Clear menu search",
"noMenuResults": "No matching pages.",
"community": "Community",
"monitoring": "Monitoring",
"staffAccess": "Ranks & permissions",