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:
commit
9b83cdc3c3
94 files changed
+6356
-1432
No files matched your search
@@ -76,3 +76,8 @@ FLARESOLVERR_URL=http://localhost:8191
|
|||||||
|
|
||||||
# Gated Housekeeping preview; never enabled in production
|
# Gated Housekeeping preview; never enabled in production
|
||||||
HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
|
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=
|
||||||
@@ -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.
|
||||||
@@ -71,6 +71,9 @@ RUN if [ "$PACKAGE_MANAGER" = "yarn" ] || { [ -z "$PACKAGE_MANAGER" ] && [ -f ya
|
|||||||
# --- Runtime stage ---
|
# --- Runtime stage ---
|
||||||
FROM node:26.8.1-bookworm-slim AS runner
|
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 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
|
# 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
|
# www-data user with UID/GID 33, which matches that ownership — so we run as
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
||||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
"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": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { logger } from "../src/lib/logger";
|
import { logger } from "../src/lib/logger";
|
||||||
import { redis } from "../src/lib/redis";
|
import { redis } from "../src/lib/redis";
|
||||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
||||||
|
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
|
||||||
import { rcon } from "../src/lib/services/rcon";
|
import { rcon } from "../src/lib/services/rcon";
|
||||||
|
|
||||||
function captureWorkerError(err: unknown, context: string): void {
|
function captureWorkerError(err: unknown, context: string): void {
|
||||||
@@ -261,6 +262,11 @@ async function publishScheduledArticles(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
new Cron("* * * * *", () => {
|
||||||
|
runCatalogExport().catch((e) =>
|
||||||
|
captureWorkerError(e, "Catalog export failed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
logger.info("Worker started", { module: "jobs" });
|
logger.info("Worker started", { module: "jobs" });
|
||||||
|
|
||||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
||||||
|
|||||||
+174
-155
@@ -5,6 +5,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
|
import { CatalogItemsBc, CatalogPagesBc, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
|
||||||
@@ -52,39 +53,43 @@ export async function updateBcPage({
|
|||||||
...fields
|
...fields
|
||||||
}: { id: number } & Record<string, unknown>) {
|
}: { id: number } & Record<string, unknown>) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const data = pickAllowed(fields, BC_PAGE_FIELDS);
|
return await withCatalogExport(async () => {
|
||||||
if (Object.keys(data).length === 0) {
|
const data = pickAllowed(fields, BC_PAGE_FIELDS);
|
||||||
return { ok: false as const, error: "No valid fields to update" };
|
if (Object.keys(data).length === 0) {
|
||||||
}
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
await db
|
}
|
||||||
.update(CatalogPagesBc)
|
await db
|
||||||
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
|
.update(CatalogPagesBc)
|
||||||
.where(eq(CatalogPagesBc.id, id));
|
.set(data as Partial<typeof CatalogPagesBc.$inferInsert>)
|
||||||
await rcon.updateCatalog();
|
.where(eq(CatalogPagesBc.id, id));
|
||||||
await logStaffActivity({
|
await rcon.updateCatalog();
|
||||||
staffId: staff.id,
|
await logStaffActivity({
|
||||||
action: "bc_page_update",
|
staffId: staff.id,
|
||||||
description: `Updated BC catalog page #${id}`,
|
action: "bc_page_update",
|
||||||
targetType: "catalog_page_bc",
|
description: `Updated BC catalog page #${id}`,
|
||||||
targetId: id,
|
targetType: "catalog_page_bc",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
|
return { ok: true as const };
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBcItem({ id }: { id: number }) {
|
export async function deleteBcItem({ id }: { id: number }) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
|
return await withCatalogExport(async () => {
|
||||||
await rcon.updateCatalog();
|
await db.delete(CatalogItemsBc).where(eq(CatalogItemsBc.id, id));
|
||||||
await logStaffActivity({
|
await rcon.updateCatalog();
|
||||||
staffId: staff.id,
|
await logStaffActivity({
|
||||||
action: "bc_item_delete",
|
staffId: staff.id,
|
||||||
description: `Deleted BC catalog item #${id}`,
|
action: "bc_item_delete",
|
||||||
targetType: "catalog_item_bc",
|
description: `Deleted BC catalog item #${id}`,
|
||||||
targetId: id,
|
targetType: "catalog_item_bc",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
|
return { ok: true as const };
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBcItem({
|
export async function updateBcItem({
|
||||||
@@ -98,24 +103,26 @@ export async function updateBcItem({
|
|||||||
extradata?: string;
|
extradata?: string;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
|
return await withCatalogExport(async () => {
|
||||||
if (Object.keys(safe).length === 0) {
|
const safe = pickAllowed(data as Record<string, unknown>, BC_ITEM_FIELDS);
|
||||||
return { ok: false as const, error: "No valid fields to update" };
|
if (Object.keys(safe).length === 0) {
|
||||||
}
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
await db
|
}
|
||||||
.update(CatalogItemsBc)
|
await db
|
||||||
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
|
.update(CatalogItemsBc)
|
||||||
.where(eq(CatalogItemsBc.id, id));
|
.set(safe as Partial<typeof CatalogItemsBc.$inferInsert>)
|
||||||
await rcon.updateCatalog();
|
.where(eq(CatalogItemsBc.id, id));
|
||||||
await logStaffActivity({
|
await rcon.updateCatalog();
|
||||||
staffId: staff.id,
|
await logStaffActivity({
|
||||||
action: "bc_item_update",
|
staffId: staff.id,
|
||||||
description: `Updated BC catalog item #${id}`,
|
action: "bc_item_update",
|
||||||
targetType: "catalog_item_bc",
|
description: `Updated BC catalog item #${id}`,
|
||||||
targetId: id,
|
targetType: "catalog_item_bc",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
|
return { ok: true as const };
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
|
||||||
return { ok: true as const };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBcItem({
|
export async function createBcItem({
|
||||||
@@ -129,18 +136,22 @@ export async function createBcItem({
|
|||||||
extradata: string;
|
extradata: string;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const [result] = await db.insert(CatalogItemsBc).values({ pageId, ...data });
|
return await withCatalogExport(async () => {
|
||||||
const createdId = Number(result.insertId);
|
const [result] = await db
|
||||||
await rcon.updateCatalog();
|
.insert(CatalogItemsBc)
|
||||||
await logStaffActivity({
|
.values({ pageId, ...data });
|
||||||
staffId: staff.id,
|
const createdId = Number(result.insertId);
|
||||||
action: "bc_item_create",
|
await rcon.updateCatalog();
|
||||||
description: `Created BC catalog item #${createdId}`,
|
await logStaffActivity({
|
||||||
targetType: "catalog_item_bc",
|
staffId: staff.id,
|
||||||
targetId: createdId,
|
action: "bc_item_create",
|
||||||
|
description: `Created BC catalog item #${createdId}`,
|
||||||
|
targetType: "catalog_item_bc",
|
||||||
|
targetId: createdId,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
|
return { ok: true as const, data: { id: createdId } };
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
|
||||||
return { ok: true as const, data: { id: createdId } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleBcPage({
|
export async function toggleBcPage({
|
||||||
@@ -151,22 +162,24 @@ export async function toggleBcPage({
|
|||||||
field: "enabled" | "visible";
|
field: "enabled" | "visible";
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const [page] = await db
|
return await withCatalogExport(async () => {
|
||||||
.select({
|
const [page] = await db
|
||||||
enabled: CatalogPagesBc.enabled,
|
.select({
|
||||||
visible: CatalogPagesBc.visible,
|
enabled: CatalogPagesBc.enabled,
|
||||||
})
|
visible: CatalogPagesBc.visible,
|
||||||
.from(CatalogPagesBc)
|
})
|
||||||
.where(eq(CatalogPagesBc.id, id))
|
.from(CatalogPagesBc)
|
||||||
.limit(1);
|
.where(eq(CatalogPagesBc.id, id))
|
||||||
if (!page) return { ok: false as const, error: "Page not found" };
|
.limit(1);
|
||||||
await db
|
if (!page) return { ok: false as const, error: "Page not found" };
|
||||||
.update(CatalogPagesBc)
|
await db
|
||||||
.set({ [field]: page[field] === "1" ? "0" : "1" })
|
.update(CatalogPagesBc)
|
||||||
.where(eq(CatalogPagesBc.id, id));
|
.set({ [field]: page[field] === "1" ? "0" : "1" })
|
||||||
await rcon.updateCatalog();
|
.where(eq(CatalogPagesBc.id, id));
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
await rcon.updateCatalog();
|
||||||
return { ok: true as const };
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBcPage(input: {
|
export async function createBcPage(input: {
|
||||||
@@ -180,30 +193,32 @@ export async function createBcPage(input: {
|
|||||||
orderNum?: number;
|
orderNum?: number;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const [result] = await db.insert(CatalogPagesBc).values({
|
return await withCatalogExport(async () => {
|
||||||
caption: input.caption,
|
const [result] = await db.insert(CatalogPagesBc).values({
|
||||||
parentId: input.parentId,
|
caption: input.caption,
|
||||||
pageLayout: input.pageLayout ?? "default_3x3",
|
parentId: input.parentId,
|
||||||
iconColor: input.iconColor ?? 0,
|
pageLayout: input.pageLayout ?? "default_3x3",
|
||||||
iconImage: input.iconImage ?? 0,
|
iconColor: input.iconColor ?? 0,
|
||||||
orderNum: input.orderNum ?? 0,
|
iconImage: input.iconImage ?? 0,
|
||||||
visible: input.visible ?? "1",
|
orderNum: input.orderNum ?? 0,
|
||||||
enabled: input.enabled ?? "1",
|
visible: input.visible ?? "1",
|
||||||
pageHeadline: "",
|
enabled: input.enabled ?? "1",
|
||||||
pageTeaser: "",
|
pageHeadline: "",
|
||||||
|
pageTeaser: "",
|
||||||
|
});
|
||||||
|
const createdId = Number(result.insertId);
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "bc_page_create",
|
||||||
|
description: `Created BC catalog page "${input.caption}"`,
|
||||||
|
targetType: "catalog_page_bc",
|
||||||
|
targetId: createdId,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
|
return { ok: true as const, data: { id: createdId } };
|
||||||
});
|
});
|
||||||
const createdId = Number(result.insertId);
|
|
||||||
await rcon.updateCatalog();
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "bc_page_create",
|
|
||||||
description: `Created BC catalog page "${input.caption}"`,
|
|
||||||
targetType: "catalog_page_bc",
|
|
||||||
targetId: createdId,
|
|
||||||
});
|
|
||||||
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> {
|
async function moveBcPage(pageId: number, newParentId: number): Promise<void> {
|
||||||
@@ -234,24 +249,26 @@ export async function reorderBcTreePage(input: {
|
|||||||
newOrderNum: number;
|
newOrderNum: number;
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
if (input.newParentId !== undefined) {
|
return await withCatalogExport(async () => {
|
||||||
try {
|
if (input.newParentId !== undefined) {
|
||||||
await moveBcPage(input.pageId, input.newParentId);
|
try {
|
||||||
} catch (err) {
|
await moveBcPage(input.pageId, input.newParentId);
|
||||||
return {
|
} catch (err) {
|
||||||
ok: false as const,
|
return {
|
||||||
error: err instanceof Error ? err.message : "Invalid move",
|
ok: false as const,
|
||||||
};
|
error: err instanceof Error ? err.message : "Invalid move",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
await db
|
||||||
await db
|
.update(CatalogPagesBc)
|
||||||
.update(CatalogPagesBc)
|
.set({ orderNum: input.newOrderNum })
|
||||||
.set({ orderNum: input.newOrderNum })
|
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||||
.where(eq(CatalogPagesBc.id, input.pageId));
|
await rcon.updateCatalog();
|
||||||
await rcon.updateCatalog();
|
revalidatePath("/admin/catalog");
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
return { ok: true as const, data: {} };
|
||||||
return { ok: true as const, data: {} };
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBcTreePage(input: {
|
export async function deleteBcTreePage(input: {
|
||||||
@@ -259,52 +276,54 @@ export async function deleteBcTreePage(input: {
|
|||||||
mode: "reparent" | "cascade";
|
mode: "reparent" | "cascade";
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const [page] = await db
|
return await withCatalogExport(async () => {
|
||||||
.select({ parentId: CatalogPagesBc.parentId })
|
const [page] = await db
|
||||||
.from(CatalogPagesBc)
|
.select({ parentId: CatalogPagesBc.parentId })
|
||||||
.where(eq(CatalogPagesBc.id, input.pageId))
|
.from(CatalogPagesBc)
|
||||||
.limit(1);
|
.where(eq(CatalogPagesBc.id, input.pageId))
|
||||||
if (!page) return { ok: false as const, error: "Page not found" };
|
.limit(1);
|
||||||
|
if (!page) return { ok: false as const, error: "Page not found" };
|
||||||
|
|
||||||
if (input.mode === "reparent") {
|
if (input.mode === "reparent") {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx
|
await tx
|
||||||
.update(CatalogPagesBc)
|
.update(CatalogPagesBc)
|
||||||
.set({ parentId: page.parentId })
|
.set({ parentId: page.parentId })
|
||||||
.where(eq(CatalogPagesBc.parentId, input.pageId));
|
.where(eq(CatalogPagesBc.parentId, input.pageId));
|
||||||
await tx
|
await tx
|
||||||
.delete(CatalogItemsBc)
|
.delete(CatalogItemsBc)
|
||||||
.where(eq(CatalogItemsBc.pageId, input.pageId));
|
.where(eq(CatalogItemsBc.pageId, input.pageId));
|
||||||
await tx
|
await tx
|
||||||
.delete(CatalogPagesBc)
|
.delete(CatalogPagesBc)
|
||||||
.where(eq(CatalogPagesBc.id, input.pageId));
|
.where(eq(CatalogPagesBc.id, input.pageId));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const toDelete: number[] = [input.pageId];
|
const toDelete: number[] = [input.pageId];
|
||||||
const queue: number[] = [input.pageId];
|
const queue: number[] = [input.pageId];
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const children = await db
|
const children = await db
|
||||||
.select({ id: CatalogPagesBc.id })
|
.select({ id: CatalogPagesBc.id })
|
||||||
.from(CatalogPagesBc)
|
.from(CatalogPagesBc)
|
||||||
.where(inArray(CatalogPagesBc.parentId, queue));
|
.where(inArray(CatalogPagesBc.parentId, queue));
|
||||||
queue.length = 0;
|
queue.length = 0;
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
toDelete.push(child.id);
|
toDelete.push(child.id);
|
||||||
queue.push(child.id);
|
queue.push(child.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.delete(CatalogItemsBc)
|
||||||
|
.where(inArray(CatalogItemsBc.pageId, toDelete));
|
||||||
|
await tx
|
||||||
|
.delete(CatalogPagesBc)
|
||||||
|
.where(inArray(CatalogPagesBc.id, toDelete));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
await tx
|
|
||||||
.delete(CatalogItemsBc)
|
|
||||||
.where(inArray(CatalogItemsBc.pageId, toDelete));
|
|
||||||
await tx
|
|
||||||
.delete(CatalogPagesBc)
|
|
||||||
.where(inArray(CatalogPagesBc.id, toDelete));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
+267
-251
@@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
|
|||||||
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
import { CatalogItems, db, ItemsBase } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
@@ -123,32 +124,34 @@ export async function createCatalogItem(data: {
|
|||||||
clubOnly: "0" | "1";
|
clubOnly: "0" | "1";
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
let catalogName = data.catalogName.trim();
|
return await withCatalogExport(async () => {
|
||||||
if (!catalogName) {
|
let catalogName = data.catalogName.trim();
|
||||||
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
if (!catalogName) {
|
||||||
if (firstId > 0) {
|
const firstId = Number.parseInt(data.itemIds.split(";")[0] || "", 10);
|
||||||
const [base] = await db
|
if (firstId > 0) {
|
||||||
.select({
|
const [base] = await db
|
||||||
publicName: ItemsBase.publicName,
|
.select({
|
||||||
itemName: ItemsBase.itemName,
|
publicName: ItemsBase.publicName,
|
||||||
})
|
itemName: ItemsBase.itemName,
|
||||||
.from(ItemsBase)
|
})
|
||||||
.where(eq(ItemsBase.id, firstId))
|
.from(ItemsBase)
|
||||||
.limit(1);
|
.where(eq(ItemsBase.id, firstId))
|
||||||
catalogName = base?.publicName || base?.itemName || String(firstId);
|
.limit(1);
|
||||||
|
catalogName = base?.publicName || base?.itemName || String(firstId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
const id = await insertCatalogItemRow({ ...data, catalogName });
|
||||||
const id = await insertCatalogItemRow({ ...data, catalogName });
|
await rcon.updateCatalog();
|
||||||
await rcon.updateCatalog();
|
await logStaffActivity({
|
||||||
await logStaffActivity({
|
staffId: staff.id,
|
||||||
staffId: staff.id,
|
action: "catalog_item_create",
|
||||||
action: "catalog_item_create",
|
description: `Created catalog item #${id}`,
|
||||||
description: `Created catalog item #${id}`,
|
targetType: "catalog_item",
|
||||||
targetType: "catalog_item",
|
targetId: id,
|
||||||
targetId: id,
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: { id } };
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
|
||||||
return { ok: true as const, data: { id } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bulk create with one RCON refresh at the end. */
|
/** Bulk create with one RCON refresh at the end. */
|
||||||
@@ -165,84 +168,88 @@ export async function bulkCreateCatalogItems({
|
|||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
if (rows.length === 0) {
|
return await withCatalogExport(async () => {
|
||||||
return { ok: true as const, data: { created: 0, failed: 0 } };
|
if (rows.length === 0) {
|
||||||
}
|
return { ok: true as const, data: { created: 0, failed: 0 } };
|
||||||
if (rows.length > 500) {
|
|
||||||
return { ok: false as const, error: "Max 500 items per bulk import" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
|
||||||
const bases = await db
|
|
||||||
.select({
|
|
||||||
id: ItemsBase.id,
|
|
||||||
publicName: ItemsBase.publicName,
|
|
||||||
itemName: ItemsBase.itemName,
|
|
||||||
})
|
|
||||||
.from(ItemsBase)
|
|
||||||
.where(inArray(ItemsBase.id, baseIds));
|
|
||||||
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
|
||||||
|
|
||||||
let created = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const base = baseMap.get(row.baseId);
|
|
||||||
if (!base) {
|
|
||||||
failed++;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
try {
|
if (rows.length > 500) {
|
||||||
await insertCatalogItemRow({
|
return { ok: false as const, error: "Max 500 items per bulk import" };
|
||||||
pageId,
|
}
|
||||||
itemIds: String(row.baseId),
|
|
||||||
catalogName: base.publicName || base.itemName || String(row.baseId),
|
const baseIds = [...new Set(rows.map((r) => r.baseId))];
|
||||||
costCredits: row.credits ?? 0,
|
const bases = await db
|
||||||
costPoints: row.points ?? 0,
|
.select({
|
||||||
pointsType: row.pointsType ?? 0,
|
id: ItemsBase.id,
|
||||||
amount: 1,
|
publicName: ItemsBase.publicName,
|
||||||
limitedSells: 0,
|
itemName: ItemsBase.itemName,
|
||||||
limitedStack: 0,
|
})
|
||||||
orderNumber: 1,
|
.from(ItemsBase)
|
||||||
offerId: -1,
|
.where(inArray(ItemsBase.id, baseIds));
|
||||||
songId: 0,
|
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||||
haveOffer: "1",
|
|
||||||
clubOnly: "0",
|
let created = 0;
|
||||||
extradata: "",
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const base = baseMap.get(row.baseId);
|
||||||
|
if (!base) {
|
||||||
|
failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await insertCatalogItemRow({
|
||||||
|
pageId,
|
||||||
|
itemIds: String(row.baseId),
|
||||||
|
catalogName: base.publicName || base.itemName || String(row.baseId),
|
||||||
|
costCredits: row.credits ?? 0,
|
||||||
|
costPoints: row.points ?? 0,
|
||||||
|
pointsType: row.pointsType ?? 0,
|
||||||
|
amount: 1,
|
||||||
|
limitedSells: 0,
|
||||||
|
limitedStack: 0,
|
||||||
|
orderNumber: 1,
|
||||||
|
offerId: -1,
|
||||||
|
songId: 0,
|
||||||
|
haveOffer: "1",
|
||||||
|
clubOnly: "0",
|
||||||
|
extradata: "",
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created > 0) {
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_items_bulk_create",
|
||||||
|
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
|
||||||
|
targetType: "catalog_page",
|
||||||
|
targetId: pageId,
|
||||||
});
|
});
|
||||||
created++;
|
revalidatePath("/admin/catalog");
|
||||||
} catch {
|
|
||||||
failed++;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (created > 0) {
|
return { ok: true as const, data: { created, failed } };
|
||||||
await rcon.updateCatalog();
|
});
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "catalog_items_bulk_create",
|
|
||||||
description: `Bulk imported ${created} catalog item(s) on page #${pageId}`,
|
|
||||||
targetType: "catalog_page",
|
|
||||||
targetId: pageId,
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/catalog");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true as const, data: { created, failed } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
|
return await withCatalogExport(async () => {
|
||||||
await rcon.updateCatalog();
|
await db.delete(CatalogItems).where(inArray(CatalogItems.id, ids));
|
||||||
await logStaffActivity({
|
await rcon.updateCatalog();
|
||||||
staffId: staff.id,
|
await logStaffActivity({
|
||||||
action: "catalog_items_delete",
|
staffId: staff.id,
|
||||||
description: `Deleted catalog items: ${ids.join(", ")}`,
|
action: "catalog_items_delete",
|
||||||
targetType: "catalog_item",
|
description: `Deleted catalog items: ${ids.join(", ")}`,
|
||||||
|
targetType: "catalog_item",
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: {} };
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
|
||||||
return { ok: true as const, data: {} };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveCatalogItems({
|
export async function moveCatalogItems({
|
||||||
@@ -253,11 +260,12 @@ export async function moveCatalogItems({
|
|||||||
targetPageId: number;
|
targetPageId: number;
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
if (ids.length === 0) {
|
return await withCatalogExport(async () => {
|
||||||
return { ok: true as const, data: {} };
|
if (ids.length === 0) {
|
||||||
}
|
return { ok: true as const, data: {} };
|
||||||
const pageIdStr = String(targetPageId);
|
}
|
||||||
await db.execute(sql`
|
const pageIdStr = String(targetPageId);
|
||||||
|
await db.execute(sql`
|
||||||
UPDATE catalog_items
|
UPDATE catalog_items
|
||||||
SET page_id = ${pageIdStr}
|
SET page_id = ${pageIdStr}
|
||||||
WHERE id IN (${sql.join(
|
WHERE id IN (${sql.join(
|
||||||
@@ -265,9 +273,10 @@ export async function moveCatalogItems({
|
|||||||
sql`, `,
|
sql`, `,
|
||||||
)})
|
)})
|
||||||
`);
|
`);
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
return { ok: true as const, data: {} };
|
return { ok: true as const, data: {} };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderCatalogItems({
|
export async function reorderCatalogItems({
|
||||||
@@ -276,15 +285,17 @@ export async function reorderCatalogItems({
|
|||||||
orders: Array<{ id: number; orderNumber: number }>;
|
orders: Array<{ id: number; orderNumber: number }>;
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
for (const { id, orderNumber } of orders) {
|
return await withCatalogExport(async () => {
|
||||||
await db
|
for (const { id, orderNumber } of orders) {
|
||||||
.update(CatalogItems)
|
await db
|
||||||
.set({ orderNumber })
|
.update(CatalogItems)
|
||||||
.where(eq(CatalogItems.id, id));
|
.set({ orderNumber })
|
||||||
}
|
.where(eq(CatalogItems.id, id));
|
||||||
await rcon.updateCatalog();
|
}
|
||||||
revalidatePath("/admin/catalog");
|
await rcon.updateCatalog();
|
||||||
return { ok: true as const, data: {} };
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: {} };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCatalogItem({
|
export async function updateCatalogItem({
|
||||||
@@ -297,46 +308,48 @@ export async function updateCatalogItem({
|
|||||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
|
return await withCatalogExport(async () => {
|
||||||
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
|
const safeCatalog = pickAllowed(catalogFields, CATALOG_ITEM_FIELDS);
|
||||||
return { ok: false as const, error: "No valid fields to update" };
|
if (Object.keys(safeCatalog).length === 0 && !baseItem) {
|
||||||
}
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
|
}
|
||||||
|
|
||||||
// page_id is often VARCHAR — update it via raw SQL when present.
|
// page_id is often VARCHAR — update it via raw SQL when present.
|
||||||
const pageIdRaw = safeCatalog.pageId;
|
const pageIdRaw = safeCatalog.pageId;
|
||||||
if (pageIdRaw !== undefined) {
|
if (pageIdRaw !== undefined) {
|
||||||
const pageIdStr = String(pageIdRaw);
|
const pageIdStr = String(pageIdRaw);
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
UPDATE catalog_items SET page_id = ${pageIdStr} WHERE id = ${id}
|
||||||
`);
|
`);
|
||||||
delete safeCatalog.pageId;
|
delete safeCatalog.pageId;
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(safeCatalog).length > 0) {
|
|
||||||
await db
|
|
||||||
.update(CatalogItems)
|
|
||||||
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
|
|
||||||
.where(eq(CatalogItems.id, id));
|
|
||||||
}
|
|
||||||
if (baseItem) {
|
|
||||||
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
|
||||||
if (Object.keys(safeBase).length > 0) {
|
|
||||||
await db
|
|
||||||
.update(ItemsBase)
|
|
||||||
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
|
|
||||||
.where(eq(ItemsBase.id, baseItem.id));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
await rcon.updateCatalog();
|
if (Object.keys(safeCatalog).length > 0) {
|
||||||
await logStaffActivity({
|
await db
|
||||||
staffId: staff.id,
|
.update(CatalogItems)
|
||||||
action: "catalog_item_update",
|
.set(safeCatalog as Partial<typeof CatalogItems.$inferInsert>)
|
||||||
description: `Updated catalog item #${id}`,
|
.where(eq(CatalogItems.id, id));
|
||||||
targetType: "catalog_item",
|
}
|
||||||
targetId: id,
|
if (baseItem) {
|
||||||
|
const safeBase = pickAllowed(baseItem.fields, ITEMS_BASE_FIELDS);
|
||||||
|
if (Object.keys(safeBase).length > 0) {
|
||||||
|
await db
|
||||||
|
.update(ItemsBase)
|
||||||
|
.set(safeBase as Partial<typeof ItemsBase.$inferInsert>)
|
||||||
|
.where(eq(ItemsBase.id, baseItem.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_item_update",
|
||||||
|
description: `Updated catalog item #${id}`,
|
||||||
|
targetType: "catalog_item",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: {} };
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
|
||||||
return { ok: true as const, data: {} };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function translateCatalogItems(input: {
|
export async function translateCatalogItems(input: {
|
||||||
@@ -344,121 +357,124 @@ export async function translateCatalogItems(input: {
|
|||||||
items: Array<{ id: number; publicName: string; description?: string }>;
|
items: Array<{ id: number; publicName: string; description?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const parsed = translateItemsSchema.safeParse(input);
|
return await withCatalogExport(async () => {
|
||||||
if (!parsed.success) {
|
const parsed = translateItemsSchema.safeParse(input);
|
||||||
return {
|
if (!parsed.success) {
|
||||||
ok: false as const,
|
return {
|
||||||
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
|
ok: false as const,
|
||||||
};
|
error: parsed.error.issues[0]?.message ?? "Invalid translate payload",
|
||||||
}
|
};
|
||||||
const { items } = parsed.data;
|
}
|
||||||
const { invalidateFurniDataCache } = await import(
|
const { items } = parsed.data;
|
||||||
"@/lib/services/catalog-items-loader"
|
const { invalidateFurniDataCache } = await import(
|
||||||
);
|
"@/lib/services/catalog-items-loader"
|
||||||
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
|
);
|
||||||
|
const { patchFurniEntryNames } = await import("@/lib/services/furni-data");
|
||||||
|
|
||||||
let namesUpdated = 0;
|
let namesUpdated = 0;
|
||||||
let descriptionsUpdated = 0;
|
let descriptionsUpdated = 0;
|
||||||
const furniPatches: Array<{
|
const furniPatches: Array<{
|
||||||
classname: string;
|
classname: string;
|
||||||
itemType: string;
|
itemType: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
spriteId?: number;
|
spriteId?: number;
|
||||||
createIfMissing?: boolean;
|
createIfMissing?: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const [base] = await db
|
const [base] = await db
|
||||||
.select({
|
|
||||||
id: ItemsBase.id,
|
|
||||||
publicName: ItemsBase.publicName,
|
|
||||||
itemName: ItemsBase.itemName,
|
|
||||||
type: ItemsBase.type,
|
|
||||||
spriteId: ItemsBase.spriteId,
|
|
||||||
})
|
|
||||||
.from(ItemsBase)
|
|
||||||
.where(eq(ItemsBase.id, item.id))
|
|
||||||
.limit(1);
|
|
||||||
if (!base) continue;
|
|
||||||
|
|
||||||
const nextName = item.publicName?.trim() ?? "";
|
|
||||||
const nextDesc = item.description ?? "";
|
|
||||||
const nameChanged = nextName !== "" && nextName !== (base.publicName ?? "");
|
|
||||||
|
|
||||||
if (nameChanged) {
|
|
||||||
await db
|
|
||||||
.update(ItemsBase)
|
|
||||||
.set({ publicName: nextName })
|
|
||||||
.where(eq(ItemsBase.id, base.id));
|
|
||||||
const idStr = String(base.id);
|
|
||||||
const related = await db
|
|
||||||
.select({
|
.select({
|
||||||
id: CatalogItems.id,
|
id: ItemsBase.id,
|
||||||
catalogName: CatalogItems.catalogName,
|
publicName: ItemsBase.publicName,
|
||||||
|
itemName: ItemsBase.itemName,
|
||||||
|
type: ItemsBase.type,
|
||||||
|
spriteId: ItemsBase.spriteId,
|
||||||
})
|
})
|
||||||
.from(CatalogItems)
|
.from(ItemsBase)
|
||||||
.where(
|
.where(eq(ItemsBase.id, item.id))
|
||||||
or(
|
.limit(1);
|
||||||
eq(CatalogItems.itemIds, idStr),
|
if (!base) continue;
|
||||||
like(CatalogItems.itemIds, `${idStr};%`),
|
|
||||||
like(CatalogItems.itemIds, `%;${idStr};%`),
|
const nextName = item.publicName?.trim() ?? "";
|
||||||
like(CatalogItems.itemIds, `%;${idStr}`),
|
const nextDesc = item.description ?? "";
|
||||||
),
|
const nameChanged =
|
||||||
);
|
nextName !== "" && nextName !== (base.publicName ?? "");
|
||||||
for (const row of related) {
|
|
||||||
if (row.catalogName !== nextName) {
|
if (nameChanged) {
|
||||||
await db
|
await db
|
||||||
.update(CatalogItems)
|
.update(ItemsBase)
|
||||||
.set({ catalogName: nextName })
|
.set({ publicName: nextName })
|
||||||
.where(eq(CatalogItems.id, row.id));
|
.where(eq(ItemsBase.id, base.id));
|
||||||
|
const idStr = String(base.id);
|
||||||
|
const related = await db
|
||||||
|
.select({
|
||||||
|
id: CatalogItems.id,
|
||||||
|
catalogName: CatalogItems.catalogName,
|
||||||
|
})
|
||||||
|
.from(CatalogItems)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(CatalogItems.itemIds, idStr),
|
||||||
|
like(CatalogItems.itemIds, `${idStr};%`),
|
||||||
|
like(CatalogItems.itemIds, `%;${idStr};%`),
|
||||||
|
like(CatalogItems.itemIds, `%;${idStr}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const row of related) {
|
||||||
|
if (row.catalogName !== nextName) {
|
||||||
|
await db
|
||||||
|
.update(CatalogItems)
|
||||||
|
.set({ catalogName: nextName })
|
||||||
|
.where(eq(CatalogItems.id, row.id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
namesUpdated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextDesc !== "" || nameChanged) {
|
||||||
|
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
|
||||||
|
furniPatches.push({
|
||||||
|
classname: base.itemName,
|
||||||
|
itemType: base.type || "s",
|
||||||
|
name: nextName || base.publicName || base.itemName,
|
||||||
|
description: nextDesc,
|
||||||
|
spriteId: base.spriteId,
|
||||||
|
createIfMissing: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
namesUpdated++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextDesc !== "" || nameChanged) {
|
const furniResult =
|
||||||
descriptionsUpdated += nextDesc !== "" ? 1 : 0;
|
furniPatches.length > 0
|
||||||
furniPatches.push({
|
? await patchFurniEntryNames(furniPatches)
|
||||||
classname: base.itemName,
|
: { updated: 0, inserted: 0 };
|
||||||
itemType: base.type || "s",
|
if (furniResult.updated > 0 || furniResult.inserted > 0) {
|
||||||
name: nextName || base.publicName || base.itemName,
|
invalidateFurniDataCache();
|
||||||
description: nextDesc,
|
|
||||||
spriteId: base.spriteId,
|
|
||||||
createIfMissing: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const furniResult =
|
await rcon.updateCatalog();
|
||||||
furniPatches.length > 0
|
await logAudit({
|
||||||
? await patchFurniEntryNames(furniPatches)
|
userId: staff.id,
|
||||||
: { updated: 0, inserted: 0 };
|
action: "items_base_translate",
|
||||||
if (furniResult.updated > 0 || furniResult.inserted > 0) {
|
target: "ItemsBase",
|
||||||
invalidateFurniDataCache();
|
after: {
|
||||||
}
|
namesUpdated,
|
||||||
|
descriptionsUpdated,
|
||||||
await rcon.updateCatalog();
|
furniDataUpdated: furniResult.updated > 0,
|
||||||
await logAudit({
|
furniDataInserted: furniResult.inserted,
|
||||||
userId: staff.id,
|
},
|
||||||
action: "items_base_translate",
|
});
|
||||||
target: "ItemsBase",
|
revalidatePath("/admin/catalog");
|
||||||
after: {
|
return {
|
||||||
namesUpdated,
|
ok: true as const,
|
||||||
descriptionsUpdated,
|
data: {
|
||||||
furniDataUpdated: furniResult.updated > 0,
|
namesUpdated,
|
||||||
furniDataInserted: furniResult.inserted,
|
descriptionsUpdated,
|
||||||
},
|
furniDataUpdated: furniResult.updated,
|
||||||
|
furniDataInserted: furniResult.inserted,
|
||||||
|
updated: items.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
|
||||||
return {
|
|
||||||
ok: true as const,
|
|
||||||
data: {
|
|
||||||
namesUpdated,
|
|
||||||
descriptionsUpdated,
|
|
||||||
furniDataUpdated: furniResult.updated,
|
|
||||||
furniDataInserted: furniResult.inserted,
|
|
||||||
updated: items.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
+108
-95
@@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
|
|||||||
import { CatalogPages, db } from "@/lib/db";
|
import { CatalogPages, db } from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
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 { deletePage, movePage } from "@/lib/services/catalog-tree";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
@@ -48,42 +49,46 @@ export async function updateCatalogPage({
|
|||||||
...fields
|
...fields
|
||||||
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const data = pickPageFields(fields);
|
return await withCatalogExport(async () => {
|
||||||
if (Object.keys(data).length === 0) {
|
const data = pickPageFields(fields);
|
||||||
return { ok: false as const, error: "No valid fields to update" };
|
if (Object.keys(data).length === 0) {
|
||||||
}
|
return { ok: false as const, error: "No valid fields to update" };
|
||||||
if (typeof data.caption === "string" && !data.captionSave) {
|
}
|
||||||
data.captionSave = data.caption.slice(0, 25);
|
if (typeof data.caption === "string" && !data.captionSave) {
|
||||||
}
|
data.captionSave = data.caption.slice(0, 25);
|
||||||
await db
|
}
|
||||||
.update(CatalogPages)
|
await db
|
||||||
.set(data as Partial<typeof CatalogPages.$inferInsert>)
|
.update(CatalogPages)
|
||||||
.where(eq(CatalogPages.id, id));
|
.set(data as Partial<typeof CatalogPages.$inferInsert>)
|
||||||
await rcon.updateCatalog();
|
.where(eq(CatalogPages.id, id));
|
||||||
await logStaffActivity({
|
await rcon.updateCatalog();
|
||||||
staffId: staff.id,
|
await logStaffActivity({
|
||||||
action: "catalog_page_update",
|
staffId: staff.id,
|
||||||
description: `Updated catalog page #${id}`,
|
action: "catalog_page_update",
|
||||||
targetType: "catalog_page",
|
description: `Updated catalog page #${id}`,
|
||||||
targetId: id,
|
targetType: "catalog_page",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: {} };
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
|
||||||
return { ok: true as const, data: {} };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCatalogPage({ id }: { id: number }) {
|
export async function deleteCatalogPage({ id }: { id: number }) {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await deletePage(id, "reparent");
|
return await withCatalogExport(async () => {
|
||||||
await rcon.updateCatalog();
|
await deletePage(id, "reparent");
|
||||||
await logStaffActivity({
|
await rcon.updateCatalog();
|
||||||
staffId: staff.id,
|
await logStaffActivity({
|
||||||
action: "catalog_page_delete",
|
staffId: staff.id,
|
||||||
description: `Deleted catalog page #${id}`,
|
action: "catalog_page_delete",
|
||||||
targetType: "catalog_page",
|
description: `Deleted catalog page #${id}`,
|
||||||
targetId: id,
|
targetType: "catalog_page",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: {} };
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/catalog");
|
|
||||||
return { ok: true as const, data: {} };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleCatalogPage({
|
export async function toggleCatalogPage({
|
||||||
@@ -94,24 +99,26 @@ export async function toggleCatalogPage({
|
|||||||
action: "toggleEnabled" | "toggleVisible";
|
action: "toggleEnabled" | "toggleVisible";
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const [page] = await db
|
return await withCatalogExport(async () => {
|
||||||
.select({
|
const [page] = await db
|
||||||
enabled: CatalogPages.enabled,
|
.select({
|
||||||
visible: CatalogPages.visible,
|
enabled: CatalogPages.enabled,
|
||||||
})
|
visible: CatalogPages.visible,
|
||||||
.from(CatalogPages)
|
})
|
||||||
.where(eq(CatalogPages.id, id))
|
.from(CatalogPages)
|
||||||
.limit(1);
|
.where(eq(CatalogPages.id, id))
|
||||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
.limit(1);
|
||||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||||
await db
|
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||||
.update(CatalogPages)
|
await db
|
||||||
.set({ [field]: current === "1" ? "0" : "1" })
|
.update(CatalogPages)
|
||||||
.where(eq(CatalogPages.id, id));
|
.set({ [field]: current === "1" ? "0" : "1" })
|
||||||
await rcon.updateCatalog();
|
.where(eq(CatalogPages.id, id));
|
||||||
revalidatePath("/admin/catalog");
|
await rcon.updateCatalog();
|
||||||
return { ok: true as const, data: {} };
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: {} };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCatalogPage(input: {
|
export async function createCatalogPage(input: {
|
||||||
@@ -126,34 +133,36 @@ export async function createCatalogPage(input: {
|
|||||||
orderNum?: number;
|
orderNum?: number;
|
||||||
}): Promise<ActionResult<{ id: number }>> {
|
}): Promise<ActionResult<{ id: number }>> {
|
||||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const [result] = await db.insert(CatalogPages).values({
|
return await withCatalogExport(async () => {
|
||||||
caption: input.caption,
|
const [result] = await db.insert(CatalogPages).values({
|
||||||
parentId: input.parentId,
|
caption: input.caption,
|
||||||
pageLayout: input.pageLayout ?? "default_3x3",
|
parentId: input.parentId,
|
||||||
captionSave: input.caption.slice(0, 25),
|
pageLayout: input.pageLayout ?? "default_3x3",
|
||||||
iconColor: input.iconColor ?? 0,
|
captionSave: input.caption.slice(0, 25),
|
||||||
iconImage: input.iconImage ?? 0,
|
iconColor: input.iconColor ?? 0,
|
||||||
minRank: input.minRank ?? 1,
|
iconImage: input.iconImage ?? 0,
|
||||||
orderNum: input.orderNum ?? 0,
|
minRank: input.minRank ?? 1,
|
||||||
visible: input.visible ?? "1",
|
orderNum: input.orderNum ?? 0,
|
||||||
enabled: input.enabled ?? "1",
|
visible: input.visible ?? "1",
|
||||||
clubOnly: "0",
|
enabled: input.enabled ?? "1",
|
||||||
vipOnly: "0",
|
clubOnly: "0",
|
||||||
pageHeadline: "",
|
vipOnly: "0",
|
||||||
pageTeaser: "",
|
pageHeadline: "",
|
||||||
includes: "",
|
pageTeaser: "",
|
||||||
|
includes: "",
|
||||||
|
});
|
||||||
|
const createdId = Number(result.insertId);
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_page_create",
|
||||||
|
description: `Created catalog page "${input.caption}"`,
|
||||||
|
targetType: "catalog_page",
|
||||||
|
targetId: createdId,
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: { id: createdId } };
|
||||||
});
|
});
|
||||||
const createdId = Number(result.insertId);
|
|
||||||
await rcon.updateCatalog();
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "catalog_page_create",
|
|
||||||
description: `Created catalog page "${input.caption}"`,
|
|
||||||
targetType: "catalog_page",
|
|
||||||
targetId: createdId,
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/catalog");
|
|
||||||
return { ok: true as const, data: { id: createdId } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderTreePage(input: {
|
export async function reorderTreePage(input: {
|
||||||
@@ -162,23 +171,25 @@ export async function reorderTreePage(input: {
|
|||||||
newOrderNum: number;
|
newOrderNum: number;
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
if (input.newParentId !== undefined) {
|
return await withCatalogExport(async () => {
|
||||||
try {
|
if (input.newParentId !== undefined) {
|
||||||
await movePage(input.pageId, input.newParentId);
|
try {
|
||||||
} catch (err) {
|
await movePage(input.pageId, input.newParentId);
|
||||||
return {
|
} catch (err) {
|
||||||
ok: false as const,
|
return {
|
||||||
error: err instanceof Error ? err.message : "Invalid move",
|
ok: false as const,
|
||||||
};
|
error: err instanceof Error ? err.message : "Invalid move",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
await db
|
||||||
await db
|
.update(CatalogPages)
|
||||||
.update(CatalogPages)
|
.set({ orderNum: input.newOrderNum })
|
||||||
.set({ orderNum: input.newOrderNum })
|
.where(eq(CatalogPages.id, input.pageId));
|
||||||
.where(eq(CatalogPages.id, input.pageId));
|
await rcon.updateCatalog();
|
||||||
await rcon.updateCatalog();
|
revalidatePath("/admin/catalog");
|
||||||
revalidatePath("/admin/catalog");
|
return { ok: true as const, data: {} };
|
||||||
return { ok: true as const, data: {} };
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTreePage(input: {
|
export async function deleteTreePage(input: {
|
||||||
@@ -186,8 +197,10 @@ export async function deleteTreePage(input: {
|
|||||||
mode: "reparent" | "cascade";
|
mode: "reparent" | "cascade";
|
||||||
}) {
|
}) {
|
||||||
await requirePermission(PERMS.CATALOG_EDIT);
|
await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
await deletePage(input.pageId, input.mode);
|
return await withCatalogExport(async () => {
|
||||||
await rcon.updateCatalog();
|
await deletePage(input.pageId, input.mode);
|
||||||
revalidatePath("/admin/catalog");
|
await rcon.updateCatalog();
|
||||||
return { ok: true as const, data: {} };
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: {} };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
actionOk,
|
actionOk,
|
||||||
handleActionError,
|
handleActionError,
|
||||||
} from "@/lib/safe-action-shared";
|
} from "@/lib/safe-action-shared";
|
||||||
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||||
import type {
|
import type {
|
||||||
AlignResult,
|
AlignResult,
|
||||||
DedupResult,
|
DedupResult,
|
||||||
@@ -38,7 +39,9 @@ export async function fixSpriteIdsAction(): Promise<
|
|||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
await guard();
|
await guard();
|
||||||
return actionOk(await maintenance.fixSpriteIds());
|
return await withCatalogExport(async () => {
|
||||||
|
return actionOk(await maintenance.fixSpriteIds());
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e);
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
@@ -49,7 +52,9 @@ export async function fixCatalogOffersAction(): Promise<
|
|||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
await guard();
|
await guard();
|
||||||
return actionOk(await maintenance.fixCatalogOffers());
|
return await withCatalogExport(async () => {
|
||||||
|
return actionOk(await maintenance.fixCatalogOffers());
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e);
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
@@ -60,7 +65,9 @@ export async function reconcileIdsAction(): Promise<
|
|||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
await guard();
|
await guard();
|
||||||
return actionOk(await maintenance.reconcileIds());
|
return await withCatalogExport(async () => {
|
||||||
|
return actionOk(await maintenance.reconcileIds());
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e);
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
@@ -71,7 +78,9 @@ export async function removeDuplicateItemsBaseAction(): Promise<
|
|||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
await guard();
|
await guard();
|
||||||
return actionOk(await maintenance.removeDuplicates());
|
return await withCatalogExport(async () => {
|
||||||
|
return actionOk(await maintenance.removeDuplicates());
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e);
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
@@ -93,7 +102,9 @@ export async function applyAlignIdsAction(): Promise<
|
|||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
await guard();
|
await guard();
|
||||||
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
|
return await withCatalogExport(async () => {
|
||||||
|
return actionOk(await maintenance.forceItemsBaseIdsToFurnidata(true));
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e);
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
@@ -104,7 +115,9 @@ export async function fixEverythingAction(input?: {
|
|||||||
}): Promise<ActionResult<maintenance.FixAllResult>> {
|
}): Promise<ActionResult<maintenance.FixAllResult>> {
|
||||||
try {
|
try {
|
||||||
await guard();
|
await guard();
|
||||||
return actionOk(await maintenance.fixEverything(input ?? {}));
|
return await withCatalogExport(async () => {
|
||||||
|
return actionOk(await maintenance.fixEverything(input ?? {}));
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e);
|
return handleActionError(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { db, WebsiteSetting } from "@/lib/db";
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { actionOk } from "@/lib/safe-action-shared";
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
|
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||||
import { deleteImportedItem } from "@/lib/services/furni-import";
|
import { deleteImportedItem } from "@/lib/services/furni-import";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
|
||||||
@@ -14,10 +15,9 @@ export const deleteImportedFurni = adminAction(
|
|||||||
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
||||||
async (ctx) =>
|
async (ctx) =>
|
||||||
actionOk(
|
actionOk(
|
||||||
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
|
(await withCatalogExport(() =>
|
||||||
string,
|
deleteImportedItem(ctx.data.classname),
|
||||||
unknown
|
)) as unknown as Record<string, unknown>,
|
||||||
>,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import "./nitro-editor.css";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -240,6 +241,7 @@ export function NitroEditorDialog({
|
|||||||
setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
|
setMetadata(JSON.parse(JSON.stringify(originalMetadata)));
|
||||||
setFlags({ ...originalFlags });
|
setFlags({ ...originalFlags });
|
||||||
setJsonText(JSON.stringify(originalMetadata, null, 2));
|
setJsonText(JSON.stringify(originalMetadata, null, 2));
|
||||||
|
setJsonError(null);
|
||||||
toast.info("Reset to original values");
|
toast.info("Reset to original values");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -334,7 +336,9 @@ export function NitroEditorDialog({
|
|||||||
const hasChanges =
|
const hasChanges =
|
||||||
metadata &&
|
metadata &&
|
||||||
originalMetadata &&
|
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));
|
JSON.stringify(flags) !== JSON.stringify(originalFlags));
|
||||||
|
|
||||||
// Furni preview URL
|
// Furni preview URL
|
||||||
@@ -344,13 +348,16 @@ export function NitroEditorDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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 */}
|
{/* Header with preview */}
|
||||||
<DialogHeader className="px-6 pt-5 pb-4 border-b bg-muted/30">
|
<DialogHeader className="px-6 pt-5 pb-4 border-b bg-muted/30">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* Furni preview */}
|
{/* Furni preview */}
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundImage: "url(/images/furni_floor.png)",
|
backgroundImage: "url(/images/furni_floor.png)",
|
||||||
backgroundRepeat: "repeat",
|
backgroundRepeat: "repeat",
|
||||||
@@ -433,69 +440,100 @@ export function NitroEditorDialog({
|
|||||||
{/* ── Visual Tab ── */}
|
{/* ── Visual Tab ── */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="visual"
|
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 */}
|
{/* 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">
|
||||||
{/* Dimensions card */}
|
<div className="space-y-4">
|
||||||
<section className="rounded-lg border bg-card p-4">
|
{/* Dimensions card */}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<section className="rounded-lg border bg-card p-4">
|
||||||
<Ruler className="w-4 h-4 text-muted-foreground" />
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<h3 className="text-sm font-medium">Dimensions</h3>
|
<Ruler className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
<h3 className="text-sm font-medium">Dimensions</h3>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<Label
|
|
||||||
htmlFor="dim-x"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
X (width)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="dim-x"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={dims.x}
|
|
||||||
onChange={(e) => setDim("x", e.target.value)}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Label
|
<div>
|
||||||
htmlFor="dim-y"
|
<Label
|
||||||
className="text-xs text-muted-foreground"
|
htmlFor="dim-x"
|
||||||
>
|
className="text-xs text-muted-foreground"
|
||||||
Y (depth)
|
>
|
||||||
</Label>
|
X (width)
|
||||||
<Input
|
</Label>
|
||||||
id="dim-y"
|
<Input
|
||||||
type="number"
|
id="dim-x"
|
||||||
min={1}
|
type="number"
|
||||||
value={dims.y}
|
min={1}
|
||||||
onChange={(e) => setDim("y", e.target.value)}
|
value={dims.x}
|
||||||
className="mt-1"
|
onChange={(e) => setDim("x", e.target.value)}
|
||||||
/>
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="dim-y"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Y (depth)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="dim-y"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={dims.y}
|
||||||
|
onChange={(e) => setDim("y", e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label
|
||||||
|
htmlFor="dim-z"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Z (height)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="dim-z"
|
||||||
|
type="number"
|
||||||
|
step={0.01}
|
||||||
|
min={0}
|
||||||
|
value={dims.z}
|
||||||
|
onChange={(e) => setDim("z", e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</section>
|
||||||
<Label
|
|
||||||
htmlFor="dim-z"
|
|
||||||
className="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
Z (height)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="dim-z"
|
|
||||||
type="number"
|
|
||||||
step={0.01}
|
|
||||||
min={0}
|
|
||||||
value={dims.z}
|
|
||||||
onChange={(e) => setDim("z", e.target.value)}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</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 */}
|
{/* Interaction card */}
|
||||||
<section className="rounded-lg border bg-card p-4">
|
<section className="rounded-lg border bg-card p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<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">
|
<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>
|
<span className="text-sm">Can Stand On</span>
|
||||||
<Switch
|
<Switch
|
||||||
|
aria-label="Can Stand On"
|
||||||
checked={flags.canstandon}
|
checked={flags.canstandon}
|
||||||
onCheckedChange={(v) =>
|
onCheckedChange={(v) =>
|
||||||
setFlags((f) => ({ ...f, canstandon: !!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">
|
<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>
|
<span className="text-sm">Can Sit On</span>
|
||||||
<Switch
|
<Switch
|
||||||
|
aria-label="Can Sit On"
|
||||||
checked={flags.cansiton}
|
checked={flags.cansiton}
|
||||||
onCheckedChange={(v) =>
|
onCheckedChange={(v) =>
|
||||||
setFlags((f) => ({ ...f, cansiton: !!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">
|
<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>
|
<span className="text-sm">Can Lay On</span>
|
||||||
<Switch
|
<Switch
|
||||||
|
aria-label="Can Lay On"
|
||||||
checked={flags.canlayon}
|
checked={flags.canlayon}
|
||||||
onCheckedChange={(v) =>
|
onCheckedChange={(v) =>
|
||||||
setFlags((f) => ({ ...f, canlayon: !!v }))
|
setFlags((f) => ({ ...f, canlayon: !!v }))
|
||||||
@@ -581,58 +622,6 @@ export function NitroEditorDialog({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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 */}
|
{/* Types */}
|
||||||
<section className="rounded-lg border bg-card p-4">
|
<section className="rounded-lg border bg-card p-4">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
@@ -858,7 +847,8 @@ export function NitroEditorDialog({
|
|||||||
>
|
>
|
||||||
<div className="relative flex-1 min-h-0">
|
<div className="relative flex-1 min-h-0">
|
||||||
<Textarea
|
<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}
|
value={jsonText}
|
||||||
onChange={(e) => handleJsonTextChange(e.target.value)}
|
onChange={(e) => handleJsonTextChange(e.target.value)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
@@ -903,7 +893,13 @@ export function NitroEditorDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || loading || !metadata}
|
disabled={
|
||||||
|
saving ||
|
||||||
|
loading ||
|
||||||
|
!metadata ||
|
||||||
|
!hasChanges ||
|
||||||
|
(activeTab === "json" && !!jsonError)
|
||||||
|
}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{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;
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ export default async function AdminLayout({
|
|||||||
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
<AdminMobileWrapper sidebar={<Sidebar staff={staff} />}>
|
||||||
<div
|
<div
|
||||||
data-admin
|
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)" }}
|
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||||
>
|
>
|
||||||
<AdminTopbar staff={staff} />
|
<AdminTopbar staff={staff} />
|
||||||
|
|||||||
+105
-24
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Cat,
|
Cat,
|
||||||
|
ChevronDown,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Copy,
|
Copy,
|
||||||
Dumbbell,
|
Dumbbell,
|
||||||
@@ -15,30 +16,93 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const TABS: { href: string; label: string; icon: LucideIcon }[] = [
|
type StudioLink = { href: string; label: string; icon: LucideIcon };
|
||||||
{ href: "/admin/studio/furni", label: "Furni", icon: Puzzle },
|
const assets: StudioLink[] = [
|
||||||
{ href: "/admin/studio/upload", label: "Upload", icon: Upload },
|
{ href: "/admin/studio/furni", label: "Furniture", icon: Puzzle },
|
||||||
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
|
{ href: "/admin/studio/badges", label: "Badges", icon: BadgeCheck },
|
||||||
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
|
{ href: "/admin/studio/clothing", label: "Clothing", icon: Shirt },
|
||||||
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
|
{ href: "/admin/studio/effects", label: "Effects", icon: Dumbbell },
|
||||||
{ href: "/admin/studio/pets", label: "Pets", icon: Cat },
|
{ 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",
|
href: "/admin/studio/repair-icons",
|
||||||
label: "Repair Icons",
|
label: "Repair icons",
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
},
|
},
|
||||||
{ href: "/admin/studio/audit", label: "Audit", icon: ClipboardCheck },
|
|
||||||
{
|
{
|
||||||
href: "/admin/studio/maintenance",
|
href: "/admin/studio/maintenance",
|
||||||
label: "Catalogus onderhoud",
|
label: "Catalog maintenance",
|
||||||
icon: Wrench,
|
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({
|
export default function StudioLayout({
|
||||||
children,
|
children,
|
||||||
@@ -47,31 +111,48 @@ export default function StudioLayout({
|
|||||||
}) {
|
}) {
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
return (
|
return (
|
||||||
<div
|
<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)]">
|
||||||
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
|
<nav
|
||||||
style={{ backgroundColor: "var(--admin-canvas)" }}
|
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 }) => {
|
<div className="flex flex-wrap gap-1">
|
||||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
{assets.map(({ href, label, icon: Icon }) => (
|
||||||
return (
|
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={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(
|
className={cn(
|
||||||
"flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
|
"flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
|
||||||
active
|
activePath(pathname, href)
|
||||||
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
|
? "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}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap gap-1">
|
||||||
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { SyncAllClient } from "@/app/admin/import/sync/sync-all-client";
|
||||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
import { CatalogExportClient } from "./catalog-export-client";
|
||||||
|
|
||||||
export default async function StudioSyncPage(_props: {
|
export default async function StudioSyncPage(_props: {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
@@ -12,6 +14,13 @@ export default async function StudioSyncPage(_props: {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<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>
|
<h1 className="text-2xl font-bold tracking-tight">Sync All Sources</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">
|
||||||
Scan configured sources and automatically import any furniture that is
|
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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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,
|
revision: item.revision ?? 0,
|
||||||
category: item.category ?? "unknown",
|
category: item.category ?? "unknown",
|
||||||
skipFurniDataWrite: false,
|
skipFurniDataWrite: false,
|
||||||
|
repairExisting: body.repairExisting === true,
|
||||||
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
|
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
|
||||||
nitroBaseUrl: source?.nitroBaseUrl,
|
nitroBaseUrl: source?.nitroBaseUrl,
|
||||||
iconBaseUrl: source?.iconBaseUrl,
|
iconBaseUrl: source?.iconBaseUrl,
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { eq, sql } from "drizzle-orm";
|
|||||||
import { withAdmin } from "@/lib/api-handler";
|
import { withAdmin } from "@/lib/api-handler";
|
||||||
import { apiError, apiOk } from "@/lib/api-response";
|
import { apiError, apiOk } from "@/lib/api-response";
|
||||||
import { db, ItemsBase } from "@/lib/db";
|
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 { PERMS } from "@/lib/permissions";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { getCloneInventoryStatus } from "@/lib/services/clone-inventory";
|
import { getCloneInventoryStatus } from "@/lib/services/clone-inventory";
|
||||||
@@ -34,6 +39,7 @@ import {
|
|||||||
} from "@/lib/services/furni-import";
|
} from "@/lib/services/furni-import";
|
||||||
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
import { getOfficialHabboFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||||
import { listFurniture } from "@/lib/services/habbofurni";
|
import { listFurniture } from "@/lib/services/habbofurni";
|
||||||
|
import { streamImportResponse } from "@/lib/services/import/core/single-response";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
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);
|
const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
|
||||||
|
|
||||||
// ── Browse a custom clone source (retro hotel furnidata) ────────
|
// ── 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");
|
const sourceId = request.nextUrl.searchParams.get("source");
|
||||||
if (sourceId) {
|
if (sourceId) {
|
||||||
const source = await getSource(sourceId);
|
const source = await getSource(sourceId);
|
||||||
@@ -313,8 +324,45 @@ export const GET = withAdmin(
|
|||||||
|
|
||||||
const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, "");
|
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 enriched = items.map((item) => {
|
||||||
const imported = item.present;
|
const presence = localFurnitureStatus(
|
||||||
|
item.classname,
|
||||||
|
localIndex,
|
||||||
|
furniDataSet,
|
||||||
|
catalogIdSet,
|
||||||
|
);
|
||||||
|
const imported = presence.alreadyImported;
|
||||||
let nitroExists = false;
|
let nitroExists = false;
|
||||||
if (imported) {
|
if (imported) {
|
||||||
const starIdx = item.classname.indexOf("*");
|
const starIdx = item.classname.indexOf("*");
|
||||||
@@ -326,7 +374,17 @@ export const GET = withAdmin(
|
|||||||
/*turbopackIgnore: true*/ nitroDir,
|
/*turbopackIgnore: true*/ nitroDir,
|
||||||
`${base}.nitro`,
|
`${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 {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -334,12 +392,10 @@ export const GET = withAdmin(
|
|||||||
type: item.itemType === "i" ? "wallitem" : "flooritem",
|
type: item.itemType === "i" ? "wallitem" : "flooritem",
|
||||||
name: item.name,
|
name: item.name,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
revision: 0,
|
revision: Number(item.revision) || 0,
|
||||||
category: String(item.category ?? ""),
|
category: String(item.category ?? ""),
|
||||||
alreadyImported: imported,
|
...presence,
|
||||||
nitroExists: item.nitroExists ?? nitroExists,
|
nitroExists,
|
||||||
hasFurnidata: item.hasFurnidata,
|
|
||||||
hasCatalogEntry: item.hasCatalogEntry,
|
|
||||||
iconUrl: sourceIconBase
|
iconUrl: sourceIconBase
|
||||||
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
|
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
|
||||||
: undefined,
|
: 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
|
// Bulk-load FurnitureData and catalog references for status checks
|
||||||
const furniDataSet = new Set<string>();
|
const furniDataSet = new Set<string>();
|
||||||
const catalogIdSet = new Set<number>();
|
const catalogIdSet = new Set<number>();
|
||||||
@@ -380,7 +426,7 @@ export const GET = withAdmin(
|
|||||||
...(fd.roomitemtypes?.furnitype ?? []),
|
...(fd.roomitemtypes?.furnitype ?? []),
|
||||||
...(fd.wallitemtypes?.furnitype ?? []),
|
...(fd.wallitemtypes?.furnitype ?? []),
|
||||||
]) {
|
]) {
|
||||||
if (e.classname) furniDataSet.add(e.classname);
|
if (e.classname) furniDataSet.add(normalizeClassname(e.classname));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* best effort */
|
/* best effort */
|
||||||
@@ -407,7 +453,13 @@ export const GET = withAdmin(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const enriched = result.items.map((item) => {
|
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;
|
let nitroExists = false;
|
||||||
if (imported) {
|
if (imported) {
|
||||||
const starIdx = item.classname.indexOf("*");
|
const starIdx = item.classname.indexOf("*");
|
||||||
@@ -419,14 +471,22 @@ export const GET = withAdmin(
|
|||||||
/*turbopackIgnore: true*/ nitroDir,
|
/*turbopackIgnore: true*/ nitroDir,
|
||||||
`${base}.nitro`,
|
`${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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
alreadyImported: imported,
|
...presence,
|
||||||
nitroExists,
|
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,
|
nitroBaseUrl: source?.nitroBaseUrl || undefined,
|
||||||
iconBaseUrl: source?.iconBaseUrl || undefined,
|
iconBaseUrl: source?.iconBaseUrl || undefined,
|
||||||
skipFurniDataWrite: false,
|
skipFurniDataWrite: false,
|
||||||
|
repairExisting: body.repairExisting === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
@@ -621,143 +682,150 @@ export const POST = withAdmin(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Single import ────────────────────────────────────────────────
|
const runSingle = async () => {
|
||||||
const {
|
// ── Single import ────────────────────────────────────────────────
|
||||||
id,
|
const {
|
||||||
classname,
|
id,
|
||||||
name,
|
|
||||||
description,
|
|
||||||
type,
|
|
||||||
revision,
|
|
||||||
category,
|
|
||||||
sourceId,
|
|
||||||
updateExisting,
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!classname || !name) return apiError("Missing classname or name", 400);
|
|
||||||
|
|
||||||
let source: Awaited<ReturnType<typeof getSource>> | null = null;
|
|
||||||
if (sourceId) source = await getSource(sourceId);
|
|
||||||
|
|
||||||
const result = await importSingleFurni({
|
|
||||||
id: id ?? 0,
|
|
||||||
classname,
|
|
||||||
name,
|
|
||||||
description: description ?? "",
|
|
||||||
type: type ?? "flooritem",
|
|
||||||
revision: revision ?? 0,
|
|
||||||
category: category ?? "unknown",
|
|
||||||
updateExisting: updateExisting === true,
|
|
||||||
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
|
|
||||||
nitroBaseUrl: source?.nitroBaseUrl || undefined,
|
|
||||||
iconBaseUrl: source?.iconBaseUrl || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.ok) return apiError(result.error ?? "Import failed", 409);
|
|
||||||
|
|
||||||
// Refresh game server caches
|
|
||||||
try {
|
|
||||||
await rcon.updateCatalog();
|
|
||||||
await rcon.updateItems();
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
"[import-furni] RCON update failed:",
|
|
||||||
(err as Error).message,
|
|
||||||
);
|
|
||||||
result.warnings.push("RCON cache refresh failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force offer_id to mirror its catalog row id across the tree
|
|
||||||
const reconciled = await reconcileImportedOfferIds();
|
|
||||||
|
|
||||||
// Keep asset + furnidata folders readable by the web server
|
|
||||||
const ownership = await ensureFurniOwnership();
|
|
||||||
|
|
||||||
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
|
|
||||||
const assetsSynced = await syncAssetsToGamedataBundle();
|
|
||||||
|
|
||||||
// Verify the furnidata matches the DB ids
|
|
||||||
const furniReconcile = await reconcileFurniDataWithItemsBase();
|
|
||||||
|
|
||||||
// Fix database consistency (catalog_name + have_offer + cost_credits) after import
|
|
||||||
const { catalogNameFixed, haveOfferFixed, costCreditsFixed } =
|
|
||||||
await fixDatabaseConsistencyAfterImport();
|
|
||||||
|
|
||||||
// Force every catalog offer_id to equal its own row id
|
|
||||||
const offerRebuild = await rebuildCatalogOfferIds();
|
|
||||||
|
|
||||||
// Repair any sprite_id drift so the client renders the right item
|
|
||||||
const spriteVerify = await verifyAndFixSpriteIds();
|
|
||||||
|
|
||||||
// Verify + fix interaction_modes_count (single consolidated pass)
|
|
||||||
const verification = await verifyAndFixInteractionModesCount();
|
|
||||||
|
|
||||||
// Patch localized furnidata files for this single furni (best effort).
|
|
||||||
// Same `translate` / `langs` contract as the batch path above.
|
|
||||||
let languagesPatch: Awaited<
|
|
||||||
ReturnType<typeof patchLocalizedFurniDataEntries>
|
|
||||||
> = [];
|
|
||||||
try {
|
|
||||||
if (body.translate !== false) {
|
|
||||||
const langs = Array.isArray(body.langs)
|
|
||||||
? (body.langs as unknown[]).filter(
|
|
||||||
(l): l is string => typeof l === "string",
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
languagesPatch = await patchLocalizedFurniDataEntries(
|
|
||||||
[{ classname, name, description: description ?? "" }],
|
|
||||||
body.translate === true,
|
|
||||||
langs,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(
|
|
||||||
"[import-furni] Localized patch failed:",
|
|
||||||
(err as Error).message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "furni_import",
|
|
||||||
target: "ItemsBase",
|
|
||||||
targetId: result.itemId ?? 0,
|
|
||||||
after: {
|
|
||||||
classname,
|
classname,
|
||||||
name,
|
name,
|
||||||
spriteId: id ?? 0,
|
description,
|
||||||
type: type === "wallitem" ? "i" : "s",
|
type,
|
||||||
catalogItemId: result.catalogItemId,
|
revision,
|
||||||
dimensions: result.dimensions,
|
category,
|
||||||
spriteCount: result.spriteCount,
|
sourceId,
|
||||||
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
updateExisting,
|
||||||
},
|
} = body;
|
||||||
});
|
|
||||||
|
|
||||||
return apiOk({
|
if (!classname || !name)
|
||||||
id: result.itemId,
|
return apiError("Missing classname or name", 400);
|
||||||
catalogItemId: result.catalogItemId,
|
|
||||||
offerIdsFixed: reconciled.fixed,
|
let source: Awaited<ReturnType<typeof getSource>> | null = null;
|
||||||
ownershipFixed: ownership.fixed,
|
if (sourceId) source = await getSource(sourceId);
|
||||||
nitrosSynced: assetsSynced.copiedNitros,
|
|
||||||
iconsSynced: assetsSynced.copiedIcons,
|
const result = await importSingleFurni({
|
||||||
furniDataFixedIds: furniReconcile.fixedIds,
|
id: id ?? 0,
|
||||||
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
|
classname,
|
||||||
furniDataMissing: furniReconcile.missing,
|
name,
|
||||||
furniDataFixedConflicts: furniReconcile.fixedConflicts,
|
description: description ?? "",
|
||||||
furniDataConflicts: furniReconcile.conflicts,
|
type: type ?? "flooritem",
|
||||||
offerRebuildChecked: offerRebuild.checked,
|
revision: revision ?? 0,
|
||||||
offerRebuildFixed: offerRebuild.fixed,
|
category: category ?? "unknown",
|
||||||
spriteChecked: spriteVerify.checked,
|
updateExisting: updateExisting === true,
|
||||||
spriteFixed: spriteVerify.fixed,
|
repairExisting: body.repairExisting === true,
|
||||||
interactionFixed: verification.fixed,
|
sourceSwfBaseUrl: source?.sourceSwfBaseUrl,
|
||||||
catalogNameFixed,
|
nitroBaseUrl: source?.nitroBaseUrl || undefined,
|
||||||
haveOfferFixed,
|
iconBaseUrl: source?.iconBaseUrl || undefined,
|
||||||
costCreditsFixed,
|
});
|
||||||
languages: languagesPatch,
|
|
||||||
spriteCount: result.spriteCount,
|
if (!result.ok) return apiError(result.error ?? "Import failed", 409);
|
||||||
warnings: result.warnings,
|
|
||||||
});
|
// Refresh game server caches
|
||||||
|
try {
|
||||||
|
await rcon.updateCatalog();
|
||||||
|
await rcon.updateItems();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
"[import-furni] RCON update failed:",
|
||||||
|
(err as Error).message,
|
||||||
|
);
|
||||||
|
result.warnings.push("RCON cache refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force offer_id to mirror its catalog row id across the tree
|
||||||
|
const reconciled = await reconcileImportedOfferIds();
|
||||||
|
|
||||||
|
// Keep asset + furnidata folders readable by the web server
|
||||||
|
const ownership = await ensureFurniOwnership();
|
||||||
|
|
||||||
|
// Backfill any missing nitros and icons into the emulator's Gamedata bundle
|
||||||
|
const assetsSynced = await syncAssetsToGamedataBundle();
|
||||||
|
|
||||||
|
// Verify the furnidata matches the DB ids
|
||||||
|
const furniReconcile = await reconcileFurniDataWithItemsBase();
|
||||||
|
|
||||||
|
// Fix database consistency (catalog_name + have_offer + cost_credits) after import
|
||||||
|
const { catalogNameFixed, haveOfferFixed, costCreditsFixed } =
|
||||||
|
await fixDatabaseConsistencyAfterImport();
|
||||||
|
|
||||||
|
// Force every catalog offer_id to equal its own row id
|
||||||
|
const offerRebuild = await rebuildCatalogOfferIds();
|
||||||
|
|
||||||
|
// Repair any sprite_id drift so the client renders the right item
|
||||||
|
const spriteVerify = await verifyAndFixSpriteIds();
|
||||||
|
|
||||||
|
// Verify + fix interaction_modes_count (single consolidated pass)
|
||||||
|
const verification = await verifyAndFixInteractionModesCount();
|
||||||
|
|
||||||
|
// Patch localized furnidata files for this single furni (best effort).
|
||||||
|
// Same `translate` / `langs` contract as the batch path above.
|
||||||
|
let languagesPatch: Awaited<
|
||||||
|
ReturnType<typeof patchLocalizedFurniDataEntries>
|
||||||
|
> = [];
|
||||||
|
try {
|
||||||
|
if (body.translate !== false) {
|
||||||
|
const langs = Array.isArray(body.langs)
|
||||||
|
? (body.langs as unknown[]).filter(
|
||||||
|
(l): l is string => typeof l === "string",
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
languagesPatch = await patchLocalizedFurniDataEntries(
|
||||||
|
[{ classname, name, description: description ?? "" }],
|
||||||
|
body.translate === true,
|
||||||
|
langs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
"[import-furni] Localized patch failed:",
|
||||||
|
(err as Error).message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
userId: ctx.session.user.id,
|
||||||
|
action: "furni_import",
|
||||||
|
target: "ItemsBase",
|
||||||
|
targetId: result.itemId ?? 0,
|
||||||
|
after: {
|
||||||
|
classname,
|
||||||
|
name,
|
||||||
|
spriteId: id ?? 0,
|
||||||
|
type: type === "wallitem" ? "i" : "s",
|
||||||
|
catalogItemId: result.catalogItemId,
|
||||||
|
dimensions: result.dimensions,
|
||||||
|
spriteCount: result.spriteCount,
|
||||||
|
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiOk({
|
||||||
|
id: result.itemId,
|
||||||
|
catalogItemId: result.catalogItemId,
|
||||||
|
offerIdsFixed: reconciled.fixed,
|
||||||
|
ownershipFixed: ownership.fixed,
|
||||||
|
nitrosSynced: assetsSynced.copiedNitros,
|
||||||
|
iconsSynced: assetsSynced.copiedIcons,
|
||||||
|
furniDataFixedIds: furniReconcile.fixedIds,
|
||||||
|
furniDataFixedOfferIds: furniReconcile.fixedOfferIds,
|
||||||
|
furniDataMissing: furniReconcile.missing,
|
||||||
|
furniDataFixedConflicts: furniReconcile.fixedConflicts,
|
||||||
|
furniDataConflicts: furniReconcile.conflicts,
|
||||||
|
offerRebuildChecked: offerRebuild.checked,
|
||||||
|
offerRebuildFixed: offerRebuild.fixed,
|
||||||
|
spriteChecked: spriteVerify.checked,
|
||||||
|
spriteFixed: spriteVerify.fixed,
|
||||||
|
interactionFixed: verification.fixed,
|
||||||
|
catalogNameFixed,
|
||||||
|
haveOfferFixed,
|
||||||
|
costCreditsFixed,
|
||||||
|
languages: languagesPatch,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -42,11 +42,11 @@ export function AdminMobileWrapper({
|
|||||||
return (
|
return (
|
||||||
<AdminMobileContext.Provider value={mobile}>
|
<AdminMobileContext.Provider value={mobile}>
|
||||||
{/* Wider than public max-w-7xl (80rem), capped so it never goes full-bleed. */}
|
{/* 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="h-dvh overflow-hidden 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="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 */}
|
{/* Mobile header */}
|
||||||
<div
|
<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"
|
open ? "z-30" : "z-50"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -84,7 +84,7 @@ export function AdminMobileWrapper({
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div
|
<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"
|
open ? "translate-x-0" : "-translate-x-full"
|
||||||
}`}
|
}`}
|
||||||
style={{ transition: "transform 300ms ease-in-out" }}
|
style={{ transition: "transform 300ms ease-in-out" }}
|
||||||
@@ -93,7 +93,12 @@ export function AdminMobileWrapper({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
</AdminMobileContext.Provider>
|
</AdminMobileContext.Provider>
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ export function AdminNavLink({
|
|||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
onClick={close}
|
onClick={close}
|
||||||
|
style={{ color: "var(--admin-sidebar-text-readable)" }}
|
||||||
aria-current={active ? "page" : undefined}
|
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)] ${
|
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
|
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"
|
: "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}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate">{children}</span>
|
<span className="min-w-0 flex-1 break-words leading-5">{children}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronsDownUp,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
@@ -75,6 +81,7 @@ export function AdminSidebarNav({
|
|||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const allowed = useMemo(
|
const allowed = useMemo(
|
||||||
() => new Set(allowedPermissions),
|
() => new Set(allowedPermissions),
|
||||||
@@ -94,12 +101,39 @@ export function AdminSidebarNav({
|
|||||||
.filter((group) => group.items.length > 0);
|
.filter((group) => group.items.length > 0);
|
||||||
}, [allowed, isSuperAdmin, navConfig]);
|
}, [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]);
|
const allKeys = useMemo(() => groups.map((g) => g.labelKey), [groups]);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
setHydrated(true);
|
||||||
}, []);
|
}, [groups]);
|
||||||
|
|
||||||
// Keep the group that contains the current page open.
|
// Keep the group that contains the current page open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -163,11 +197,50 @@ export function AdminSidebarNav({
|
|||||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
aria-label={t("overview")}
|
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">
|
<div className="flex shrink-0 items-center gap-1 border-b border-[var(--admin-border)] px-3 py-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={expandAll}
|
onClick={expandAll}
|
||||||
disabled={allExpanded}
|
disabled={allExpanded || !!search}
|
||||||
title={t("expandAll")}
|
title={t("expandAll")}
|
||||||
aria-label={t("expandAll")}
|
aria-label={t("expandAll")}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -186,7 +259,7 @@ export function AdminSidebarNav({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={collapseAll}
|
onClick={collapseAll}
|
||||||
disabled={!canCollapseMore}
|
disabled={!canCollapseMore || !!search}
|
||||||
title={t("collapseAll")}
|
title={t("collapseAll")}
|
||||||
aria-label={t("collapseAll")}
|
aria-label={t("collapseAll")}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -205,9 +278,17 @@ export function AdminSidebarNav({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3">
|
<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 GroupIcon = group.icon;
|
||||||
const isCollapsed = Boolean(collapsed[group.labelKey]);
|
const isCollapsed = !search && Boolean(collapsed[group.labelKey]);
|
||||||
const panelId = `admin-nav-${group.labelKey}`;
|
const panelId = `admin-nav-${group.labelKey}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -220,12 +301,13 @@ export function AdminSidebarNav({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(group.labelKey)}
|
onClick={() => toggle(group.labelKey)}
|
||||||
|
disabled={!!search}
|
||||||
aria-expanded={!isCollapsed}
|
aria-expanded={!isCollapsed}
|
||||||
aria-controls={panelId}
|
aria-controls={panelId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left",
|
"flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left",
|
||||||
"text-[0.65rem] font-extrabold uppercase tracking-[0.14em]",
|
"text-xs font-semibold",
|
||||||
"text-[var(--admin-sidebar-text-readable)] opacity-70",
|
"text-[var(--admin-sidebar-muted-readable)]",
|
||||||
"hover:opacity-100 hover:bg-[var(--admin-accent)]/10",
|
"hover:opacity-100 hover:bg-[var(--admin-accent)]/10",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
@@ -235,6 +317,12 @@ export function AdminSidebarNav({
|
|||||||
<span className="min-w-0 flex-1 truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{t(group.labelKey)}
|
{t(group.labelKey)}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="text-xs tabular-nums text-[var(--admin-sidebar-muted-readable)]"
|
||||||
|
>
|
||||||
|
{group.items.length}
|
||||||
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={14}
|
size={14}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,44 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
import { Ban, Check, Circle, Loader2, RefreshCw, X } from "lucide-react";
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
import { memo } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
|
import type { BatchItemStatus, VerificationSummary } from "./studio-types";
|
||||||
|
|
||||||
function SummaryRow({
|
const statusLabels: Record<BatchItemStatus["status"], string> = {
|
||||||
label,
|
pending: "Waiting",
|
||||||
value,
|
started: "Starting",
|
||||||
tone,
|
downloading: "Downloading assets",
|
||||||
}: {
|
converting: "Preparing assets",
|
||||||
label: string;
|
writing_db: "Saving",
|
||||||
value: string | number;
|
done: "Imported",
|
||||||
tone?: "ok" | "warn";
|
failed: "Failed",
|
||||||
}) {
|
};
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BatchProgress = memo(function BatchProgress({
|
export const BatchProgress = memo(function BatchProgress({
|
||||||
progress,
|
progress,
|
||||||
@@ -47,7 +22,6 @@ export const BatchProgress = memo(function BatchProgress({
|
|||||||
failed,
|
failed,
|
||||||
cancelling,
|
cancelling,
|
||||||
verification,
|
verification,
|
||||||
startTime,
|
|
||||||
failedClassnames,
|
failedClassnames,
|
||||||
onCancel,
|
onCancel,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
@@ -65,192 +39,151 @@ export const BatchProgress = memo(function BatchProgress({
|
|||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
onRetry?: (classnames: string[]) => void;
|
onRetry?: (classnames: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { entries, total, completed, percent } = useMemo(() => {
|
const entries = [...progress.values()];
|
||||||
const e = [...progress.entries()];
|
const completed = entries.filter(
|
||||||
const t = e.length;
|
(item) => item.status === "done" || item.status === "failed",
|
||||||
const c = e.filter(
|
).length;
|
||||||
([, s]) => s.status === "done" || s.status === "failed",
|
const total = entries.length;
|
||||||
).length;
|
const percent = total ? Math.round((completed / total) * 100) : 0;
|
||||||
return {
|
const imported = done
|
||||||
entries: e,
|
? succeeded
|
||||||
total: t,
|
: entries.filter((item) => item.status === "done").length;
|
||||||
completed: c,
|
const errors = done
|
||||||
percent: t > 0 ? Math.round((c / t) * 100) : 0,
|
? failed
|
||||||
};
|
: entries.filter((item) => item.status === "failed").length;
|
||||||
}, [progress]);
|
const unfinished = Math.max(0, total - completed);
|
||||||
|
const heading = !done
|
||||||
// Live ETA calculation
|
? cancelling
|
||||||
const [eta, setEta] = useState<string | null>(null);
|
? "Stopping import…"
|
||||||
const completedRef = useRef(completed);
|
: "Importing furniture…"
|
||||||
const totalRef = useRef(total);
|
: unfinished
|
||||||
completedRef.current = completed;
|
? "Import stopped"
|
||||||
totalRef.current = total;
|
: errors
|
||||||
|
? "Import completed with errors"
|
||||||
useEffect(() => {
|
: "Import completed";
|
||||||
if (done || !startTime || completed === 0) {
|
const issues = verification
|
||||||
setEta(null);
|
? verification.furniDataMissing + verification.furniDataConflicts
|
||||||
return;
|
: 0;
|
||||||
}
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-[var(--admin-border)] p-3">
|
<section
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
aria-label="Import progress"
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
className="max-h-[45%] shrink-0 overflow-auto border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-4"
|
||||||
{done ? "Import finished" : "Importing…"}
|
>
|
||||||
</span>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
{done ? (
|
<div role="status" aria-live="polite">
|
||||||
<span
|
<h2
|
||||||
className={cn(
|
style={{ fontSize: 14, margin: 0 }}
|
||||||
"text-xs font-bold",
|
className="text-sm font-semibold"
|
||||||
failed > 0
|
|
||||||
? "text-[var(--admin-warning)]"
|
|
||||||
: "text-[var(--admin-success)]",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{completed}/{total} · {succeeded} ok
|
{heading}
|
||||||
{failed > 0 ? ` · ${failed} failed` : ""}
|
</h2>
|
||||||
</span>
|
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
|
||||||
) : (
|
{imported} imported · {errors} failed
|
||||||
<span className="flex items-center gap-2">
|
{unfinished > 0 &&
|
||||||
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
|
` · ${unfinished} ${done ? "not completed" : "remaining"}`}
|
||||||
{completed}/{total}
|
</p>
|
||||||
</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>
|
|
||||||
|
|
||||||
{!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>
|
||||||
)}
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{done ? (
|
||||||
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
|
<>
|
||||||
{entries.map(([classname, s]) => (
|
{!!failedClassnames?.length && onRetry && (
|
||||||
<div key={classname} className="flex items-center gap-2 text-xs">
|
<Button
|
||||||
{s.status === "done" ? (
|
size="sm"
|
||||||
<Check
|
variant="outline"
|
||||||
size={12}
|
onClick={() => onRetry(failedClassnames)}
|
||||||
className="shrink-0 text-[var(--admin-success)]"
|
>
|
||||||
/>
|
<RefreshCw size={14} />
|
||||||
) : s.status === "failed" ? (
|
Retry failed ({failedClassnames.length})
|
||||||
<X size={12} className="shrink-0 text-[var(--admin-warning)]" />
|
</Button>
|
||||||
) : (
|
)}
|
||||||
<Loader2
|
<Button size="sm" variant="secondary" onClick={onDismiss}>
|
||||||
size={12}
|
<Check size={14} />
|
||||||
className="shrink-0 animate-spin text-[var(--admin-accent)]"
|
Close summary
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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">
|
|
||||||
{done ? (
|
|
||||||
<>
|
|
||||||
{failedClassnames && failedClassnames.length > 0 && onRetry && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onRetry(failedClassnames)}
|
|
||||||
>
|
|
||||||
<Loader2 size={14} />
|
|
||||||
Retry {failedClassnames.length} failed
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</>
|
||||||
<Button size="sm" variant="secondary" onClick={onDismiss}>
|
) : (
|
||||||
<Check size={14} />
|
<Button
|
||||||
Dismiss
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={cancelling}
|
||||||
|
>
|
||||||
|
{cancelling ? (
|
||||||
|
<Loader2 className="animate-spin" size={14} />
|
||||||
|
) : (
|
||||||
|
<Ban size={14} />
|
||||||
|
)}
|
||||||
|
Stop import
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={cancelling}
|
|
||||||
>
|
|
||||||
{cancelling ? (
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Ban size={14} />
|
|
||||||
)}
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronRight, FolderTree } from "lucide-react";
|
import { ChevronDown, ChevronRight, FolderTree, Search, X } from "lucide-react";
|
||||||
import { memo, useCallback, useMemo, useState } from "react";
|
import { memo, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TreeNode } from "@/types/catalog";
|
import type { TreeNode } from "@/types/catalog";
|
||||||
|
|
||||||
@@ -10,93 +9,122 @@ export const CatalogRail = memo(function CatalogRail({
|
|||||||
tree,
|
tree,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
tree: TreeNode[];
|
tree: TreeNode[];
|
||||||
selectedId: number | null;
|
selectedId: number | null;
|
||||||
onSelect: (id: number | null) => void;
|
onSelect: (id: number | null) => void;
|
||||||
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
|
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
|
if (search.trim())
|
||||||
|
return tree.filter((node) =>
|
||||||
|
node.caption.toLowerCase().includes(search.trim().toLowerCase()),
|
||||||
|
);
|
||||||
const hidden = new Set<number>();
|
const hidden = new Set<number>();
|
||||||
for (const node of tree) {
|
for (const node of tree)
|
||||||
if (hidden.has(node.parentId)) {
|
if (hidden.has(node.parentId) || collapsed.has(node.parentId))
|
||||||
hidden.add(node.id);
|
hidden.add(node.id);
|
||||||
continue;
|
return tree.filter((node) => !hidden.has(node.id));
|
||||||
}
|
}, [tree, collapsed, search]);
|
||||||
if (collapsed.has(node.parentId)) {
|
function toggle(id: number) {
|
||||||
hidden.add(node.id);
|
setCollapsed((previous) => {
|
||||||
}
|
const next = new Set(previous);
|
||||||
}
|
|
||||||
return tree.filter((n) => !hidden.has(n.id));
|
|
||||||
}, [tree, collapsed]);
|
|
||||||
|
|
||||||
const toggle = useCallback((id: number) => {
|
|
||||||
setCollapsed((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
else next.add(id);
|
else next.add(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
return (
|
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)]">
|
<aside
|
||||||
<div className="flex items-center gap-2 border-b border-[var(--admin-border)] px-3 py-2.5">
|
aria-label="Catalog categories"
|
||||||
<FolderTree size={14} className="text-[var(--admin-accent)]" />
|
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"
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
|
>
|
||||||
Catalog
|
<div className="flex items-center gap-2 px-3 pt-3">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
|
<label className="mx-3 my-3 flex items-center gap-2 rounded-md border border-[var(--admin-border)] px-2">
|
||||||
{visible.length === 0 ? (
|
<Search size={14} />
|
||||||
<p className="px-2 py-4 text-xs text-[var(--admin-text-muted)]">
|
<input
|
||||||
No catalog pages found.
|
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>
|
</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` }}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
|
||||||
isCollapsed ? (
|
|
||||||
<ChevronRight size={12} className="shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={12} className="shrink-0" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="w-3 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate">
|
|
||||||
{isRoot ? (
|
|
||||||
<span className="font-semibold">{node.caption}</span>
|
|
||||||
) : (
|
|
||||||
node.caption
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 rounded-full bg-[var(--admin-accent)]/10 px-1.5 text-[0.6rem] font-semibold text-[var(--admin-accent)]">
|
|
||||||
{node.itemCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
|
{visible.map((node) => (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-md",
|
||||||
|
selectedId === node.id && "bg-[var(--admin-accent)]/15",
|
||||||
|
)}
|
||||||
|
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>
|
</div>
|
||||||
</aside>
|
</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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Lenis } from "lenis/react";
|
import { Lenis } from "lenis/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export function SmoothScroll() {
|
export function SmoothScroll() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isAdmin = pathname === "/admin" || pathname?.startsWith("/admin/");
|
||||||
const [prefersReduced, setPrefersReduced] = useState(true);
|
const [prefersReduced, setPrefersReduced] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -14,7 +17,8 @@ export function SmoothScroll() {
|
|||||||
return () => mq.removeEventListener("change", onChange);
|
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 (
|
return (
|
||||||
<Lenis
|
<Lenis
|
||||||
|
|||||||
+41
-2
@@ -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 { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
||||||
import { validateCsrfToken } from "@/lib/foundation/security";
|
import { validateCsrfToken } from "@/lib/foundation/security";
|
||||||
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
||||||
import { logServerError } from "@/lib/server-log";
|
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 MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||||
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
@@ -80,9 +86,42 @@ export function withAdmin(
|
|||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let finishExport: (() => Promise<void>) | undefined;
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
|
await finishExport?.().catch(() => undefined);
|
||||||
logServerError("admin.api_failed", error, {
|
logServerError("admin.api_failed", error, {
|
||||||
path: request.nextUrl.pathname,
|
path: request.nextUrl.pathname,
|
||||||
userId: context.session.user.id,
|
userId: context.session.user.id,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -304,3 +304,14 @@ describe("fetchSourceFurnidata", () => {
|
|||||||
).rejects.toThrow(/Cloudflare challenge/);
|
).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);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
|||||||
import { eq, inArray, sql } from "drizzle-orm";
|
import { eq, inArray, sql } from "drizzle-orm";
|
||||||
import { db, ItemsBase } from "@/lib/db";
|
import { db, ItemsBase } from "@/lib/db";
|
||||||
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
|
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 { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
import { extractFurniIconPng } from "@/lib/services/clone-icon";
|
||||||
@@ -68,6 +69,10 @@ function toSourceFurni(
|
|||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
id: Number(raw.id ?? 0),
|
id: Number(raw.id ?? 0),
|
||||||
|
revision:
|
||||||
|
Number.isSafeInteger(Number(raw.revision)) && Number(raw.revision) > 0
|
||||||
|
? Number(raw.revision)
|
||||||
|
: 0,
|
||||||
classname: String(raw.classname ?? ""),
|
classname: String(raw.classname ?? ""),
|
||||||
name: String(raw.name ?? raw.classname ?? ""),
|
name: String(raw.name ?? raw.classname ?? ""),
|
||||||
description: String(raw.description ?? ""),
|
description: String(raw.description ?? ""),
|
||||||
@@ -521,21 +526,24 @@ export async function getCloneList(params: {
|
|||||||
.select({ itemName: ItemsBase.itemName })
|
.select({ itemName: ItemsBase.itemName })
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(inArray(ItemsBase.itemName, allNames));
|
.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 =
|
const filtered =
|
||||||
filter === "missing"
|
filter === "missing"
|
||||||
? searched.filter((e) => !present.has(e.classname))
|
? searched.filter((e) => !present.has(normalizeClassname(e.classname)))
|
||||||
: filter === "present"
|
: filter === "present"
|
||||||
? searched.filter((e) => present.has(e.classname))
|
? searched.filter((e) => present.has(normalizeClassname(e.classname)))
|
||||||
: searched;
|
: searched;
|
||||||
|
|
||||||
const total = filtered.length;
|
const total = filtered.length;
|
||||||
const start = (Math.max(page, 1) - 1) * perPage;
|
const start = (Math.max(page, 1) - 1) * perPage;
|
||||||
const slice = filtered.slice(start, start + perPage);
|
const slice = filtered.slice(start, start + perPage);
|
||||||
return {
|
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 },
|
meta: { page: Math.max(page, 1), perPage, total },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -551,9 +559,11 @@ export async function getCloneStats(
|
|||||||
.select({ itemName: ItemsBase.itemName })
|
.select({ itemName: ItemsBase.itemName })
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(inArray(ItemsBase.itemName, classnames));
|
.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 };
|
return { total: all.length, present, clonable: all.length - present };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +582,9 @@ export async function getClonableClassnames(
|
|||||||
.select({ itemName: ItemsBase.itemName })
|
.select({ itemName: ItemsBase.itemName })
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(inArray(ItemsBase.itemName, classnames));
|
.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);
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,7 @@ describe("clone inventory status", () => {
|
|||||||
classname: "modern_chair",
|
classname: "modern_chair",
|
||||||
name: "Modern chair",
|
name: "Modern chair",
|
||||||
description: "A modern chair",
|
description: "A modern chair",
|
||||||
|
revision: 73706,
|
||||||
itemType: "s",
|
itemType: "s",
|
||||||
category: "chair",
|
category: "chair",
|
||||||
present: true,
|
present: true,
|
||||||
@@ -70,7 +71,16 @@ describe("clone inventory status", () => {
|
|||||||
dependencies.existsSync.mockReturnValue(true);
|
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({
|
const result = await getCloneInventoryStatus({
|
||||||
source: {
|
source: {
|
||||||
id: "hotel-nl",
|
id: "hotel-nl",
|
||||||
@@ -89,6 +99,7 @@ describe("clone inventory status", () => {
|
|||||||
expect(result.items[0]).toMatchObject({
|
expect(result.items[0]).toMatchObject({
|
||||||
id: 900,
|
id: 900,
|
||||||
classname: "modern_chair",
|
classname: "modern_chair",
|
||||||
|
revision: 73706,
|
||||||
hasCatalogEntry: true,
|
hasCatalogEntry: true,
|
||||||
hasFurnidata: true,
|
hasFurnidata: true,
|
||||||
nitroExists: true,
|
nitroExists: true,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { inArray, sql } from "drizzle-orm";
|
import { inArray, sql } from "drizzle-orm";
|
||||||
import { db, ItemsBase } from "@/lib/db";
|
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 { getCloneList, getCloneStats } from "@/lib/services/clone-import";
|
||||||
import type { CloneSource } from "@/lib/services/clone-sources";
|
import type { CloneSource } from "@/lib/services/clone-sources";
|
||||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||||
@@ -13,6 +15,7 @@ export interface CloneInventoryStatusItem {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly itemType: "s" | "i";
|
readonly itemType: "s" | "i";
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
|
readonly revision: number;
|
||||||
readonly category: string;
|
readonly category: string;
|
||||||
readonly present: boolean;
|
readonly present: boolean;
|
||||||
readonly nitroExists?: boolean;
|
readonly nitroExists?: boolean;
|
||||||
@@ -50,17 +53,20 @@ export async function getCloneInventoryStatus(params: {
|
|||||||
const importedNames = listResult.items
|
const importedNames = listResult.items
|
||||||
.filter((item) => item.present)
|
.filter((item) => item.present)
|
||||||
.map((item) => item.classname);
|
.map((item) => item.classname);
|
||||||
const localIdsByClassname = new Map<string, number>();
|
let localIndex = indexLocalFurniture<{ id: number; itemName: string }>([]);
|
||||||
const catalogItemIds = new Set<number>();
|
const catalogItemIds = new Set<number>();
|
||||||
if (importedNames.length > 0) {
|
if (importedNames.length > 0) {
|
||||||
try {
|
try {
|
||||||
const localItems = await db
|
const localItems = await db
|
||||||
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
|
.select({ id: ItemsBase.id, itemName: ItemsBase.itemName })
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(inArray(ItemsBase.itemName, importedNames));
|
.where(
|
||||||
for (const item of localItems) {
|
inArray(
|
||||||
if (item.itemName) localIdsByClassname.set(item.itemName, item.id);
|
sql`LOWER(TRIM(${ItemsBase.itemName}))`,
|
||||||
}
|
importedNames.map(normalizeClassname),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
localIndex = indexLocalFurniture(localItems);
|
||||||
const catalogRows = await db.execute(
|
const catalogRows = await db.execute(
|
||||||
sql`SELECT item_ids FROM catalog_items`,
|
sql`SELECT item_ids FROM catalog_items`,
|
||||||
);
|
);
|
||||||
@@ -89,7 +95,8 @@ export async function getCloneInventoryStatus(params: {
|
|||||||
...(furnidata.wallitemtypes?.furnitype ?? []),
|
...(furnidata.wallitemtypes?.furnitype ?? []),
|
||||||
]
|
]
|
||||||
.map((item) => item.classname)
|
.map((item) => item.classname)
|
||||||
.filter((classname): classname is string => Boolean(classname)),
|
.filter((classname): classname is string => Boolean(classname))
|
||||||
|
.map(normalizeClassname),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
partialDependencies.add("filesystem");
|
partialDependencies.add("filesystem");
|
||||||
@@ -111,23 +118,33 @@ export async function getCloneInventoryStatus(params: {
|
|||||||
meta: listResult.meta,
|
meta: listResult.meta,
|
||||||
partialDependencies: [...partialDependencies],
|
partialDependencies: [...partialDependencies],
|
||||||
items: listResult.items.map((item) => {
|
items: listResult.items.map((item) => {
|
||||||
const localId = localIdsByClassname.get(item.classname);
|
const classnameKey = normalizeClassname(item.classname);
|
||||||
|
const localRows = localIndex.get(classnameKey) ?? [];
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
classname: item.classname,
|
classname: item.classname,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
|
revision: Number(item.revision) || 0,
|
||||||
itemType: item.itemType,
|
itemType: item.itemType,
|
||||||
category: String(item.category ?? ""),
|
category: String(item.category ?? ""),
|
||||||
present: item.present,
|
present: item.present,
|
||||||
...(item.present && nitroExists
|
...(item.present && nitroExists
|
||||||
? { nitroExists: nitroExists(item.classname) }
|
? {
|
||||||
|
nitroExists:
|
||||||
|
nitroExists(item.classname) ||
|
||||||
|
localRows.some((row) => nitroExists?.(row.itemName)),
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(item.present && furnidataClassnames
|
...(item.present && furnidataClassnames
|
||||||
? { hasFurnidata: furnidataClassnames.has(item.classname) }
|
? { hasFurnidata: furnidataClassnames.has(classnameKey) }
|
||||||
: {}),
|
: {}),
|
||||||
...(item.present && localId !== undefined
|
...(item.present && localRows.length > 0
|
||||||
? { hasCatalogEntry: catalogItemIds.has(localId) }
|
? {
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from "@/lib/services/swf-to-nitro";
|
} from "@/lib/services/swf-to-nitro";
|
||||||
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
import { getRuntimePath } from "@/lib/utils/runtime-path";
|
||||||
import type { ImportSingleResult } from "@/types/furni";
|
import type { ImportSingleResult } from "@/types/furni";
|
||||||
|
import { reserveFurnitureId } from "./furniture-id-reservation";
|
||||||
|
|
||||||
// Re-export the download helpers now owned by the shared import core.
|
// Re-export the download helpers now owned by the shared import core.
|
||||||
// Existing furni callers keep importing them from here.
|
// Existing furni callers keep importing them from here.
|
||||||
@@ -515,6 +516,7 @@ export async function importSingleFurni(params: {
|
|||||||
category: string;
|
category: string;
|
||||||
skipFurniDataWrite?: boolean;
|
skipFurniDataWrite?: boolean;
|
||||||
updateExisting?: boolean;
|
updateExisting?: boolean;
|
||||||
|
repairExisting?: boolean;
|
||||||
onProgress?: (status: string) => void;
|
onProgress?: (status: string) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
||||||
@@ -533,6 +535,7 @@ export async function importSingleFurni(params: {
|
|||||||
category,
|
category,
|
||||||
skipFurniDataWrite,
|
skipFurniDataWrite,
|
||||||
updateExisting,
|
updateExisting,
|
||||||
|
repairExisting,
|
||||||
onProgress,
|
onProgress,
|
||||||
signal,
|
signal,
|
||||||
sourceSwfBaseUrl,
|
sourceSwfBaseUrl,
|
||||||
@@ -544,18 +547,22 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
// Check if already exists by classname OR by spriteId (primary key collision).
|
// Check if already exists by classname OR by spriteId (primary key collision).
|
||||||
const [existsByName] = await db
|
const [existsByName] = await db
|
||||||
.select({ id: ItemsBase.id })
|
.select({
|
||||||
|
id: ItemsBase.id,
|
||||||
|
spriteId: ItemsBase.spriteId,
|
||||||
|
type: ItemsBase.type,
|
||||||
|
})
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(eq(ItemsBase.itemName, classname))
|
.where(eq(ItemsBase.itemName, classname))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (existsByName && !updateExisting) {
|
if (existsByName && !updateExisting && !repairExisting) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
warnings,
|
warnings,
|
||||||
error: `Item "${classname}" already exists (id: ${existsByName.id})`,
|
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
|
// The classname already occupies a different spriteId. Reassigning the
|
||||||
// id would orphan every reference (catalog item_ids, player items,
|
// id would orphan every reference (catalog item_ids, player items,
|
||||||
// furnidata), so this can never be an "update".
|
// 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}`,
|
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
|
const [existsById] = await db
|
||||||
.select({ itemName: ItemsBase.itemName })
|
.select({ itemName: ItemsBase.itemName })
|
||||||
.from(ItemsBase)
|
.from(ItemsBase)
|
||||||
.where(eq(ItemsBase.id, originalId))
|
.where(eq(ItemsBase.id, itemId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (existsById && (!existsByName || existsById.itemName !== classname)) {
|
if (existsByName && existsById && existsById.itemName !== classname) {
|
||||||
// The spriteId is owned by a different classname. Even with
|
// The spriteId is owned by a different classname. Even with
|
||||||
// updateExisting this is a genuine conflict, not an update.
|
// updateExisting this is a genuine conflict, not an update.
|
||||||
return {
|
return {
|
||||||
@@ -583,17 +594,20 @@ export async function importSingleFurni(params: {
|
|||||||
const isUpdate = !!existsByName;
|
const isUpdate = !!existsByName;
|
||||||
|
|
||||||
// ── Check if spriteId already used in FurnitureData.json ──
|
// ── Check if spriteId already used in FurnitureData.json ──
|
||||||
const takenBy = await findFurniDataIdConflict(originalId, classname);
|
const takenBy = await findFurniDataIdConflict(spriteId, classname);
|
||||||
if (takenBy && !updateExisting) {
|
if (takenBy && isUpdate) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
warnings,
|
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 itemType = preservingExisting
|
||||||
const spriteId = originalId;
|
? existsByName.type
|
||||||
|
: type === "wallitem"
|
||||||
|
? "i"
|
||||||
|
: "s";
|
||||||
|
|
||||||
// ── Cross-reference with configured official Habbo furnidata ──────
|
// ── Cross-reference with configured official Habbo furnidata ──────
|
||||||
let officialHabbo: OfficialHabboFurniEntry | null = null;
|
let officialHabbo: OfficialHabboFurniEntry | null = null;
|
||||||
@@ -613,34 +627,8 @@ export async function importSingleFurni(params: {
|
|||||||
const bestRevision = revision || officialHabbo?.revision || 0;
|
const bestRevision = revision || officialHabbo?.revision || 0;
|
||||||
const bestCategory = category || officialHabbo?.category || "unknown";
|
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 ────────────────────────────────────────
|
// ── Derive file-safe names ────────────────────────────────────────
|
||||||
const rev = revision || 0;
|
const rev = bestRevision;
|
||||||
const assetTargets = await getFurniAssetWriteTargets();
|
const assetTargets = await getFurniAssetWriteTargets();
|
||||||
const { swfDir, iconDir, nitroDir } = assetTargets;
|
const { swfDir, iconDir, nitroDir } = assetTargets;
|
||||||
const starIdx = classname.indexOf("*");
|
const starIdx = classname.indexOf("*");
|
||||||
@@ -713,9 +701,31 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
// Download icon + SWF concurrently (independent assets). Each tries its
|
// Download icon + SWF concurrently (independent assets). Each tries its
|
||||||
// candidate URLs in order until one succeeds.
|
// candidate URLs in order until one succeeds.
|
||||||
|
let iconFailure = "",
|
||||||
|
swfFailure = "";
|
||||||
const [iconOkResult, swfOk] = await Promise.all([
|
const [iconOkResult, swfOk] = await Promise.all([
|
||||||
tryDownloadCandidates(iconUrls, iconPath, "png", signal),
|
preservingExisting && existsSync(iconPath)
|
||||||
tryDownloadCandidates(swfUrls, swfPath, "swf", signal),
|
? 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;
|
let iconOk = iconOkResult;
|
||||||
|
|
||||||
@@ -729,7 +739,7 @@ export async function importSingleFurni(params: {
|
|||||||
) {
|
) {
|
||||||
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
||||||
const dl = await downloadFile(nitroUrl, nitroPath, {
|
const dl = await downloadFile(nitroUrl, nitroPath, {
|
||||||
validate: "png",
|
validate: "nitro",
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
if (dl.ok) nitroDownloadOk = true;
|
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");
|
warnings.push("Icon download failed and SWF icon extraction also failed");
|
||||||
}
|
}
|
||||||
} else if (!iconOk) {
|
} 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 ──────────────────────────────────────────
|
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||||
onProgress?.("converting");
|
onProgress?.("converting");
|
||||||
@@ -801,7 +814,7 @@ export async function importSingleFurni(params: {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
const nitroValid = nitroStat && nitroStat.size >= 128;
|
const nitroValid = nitroStat && nitroStat.size >= 128;
|
||||||
|
|
||||||
if (!nitroValid && !conversionResult) {
|
if (!nitroValid) {
|
||||||
if (nitroStat) {
|
if (nitroStat) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
|
`Nitro file too small/corrupt (${nitroStat.size} bytes) — removing`,
|
||||||
@@ -812,17 +825,54 @@ export async function importSingleFurni(params: {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await db.execute(
|
|
||||||
sql`DELETE FROM items_base WHERE id = ${spriteId} AND item_name = ${classname}`,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* best effort cleanup */
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
warnings,
|
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
|
// Update items_base with ALL enriched data
|
||||||
try {
|
try {
|
||||||
const update = buildItemsBaseEnrichmentUpdate({
|
const update = buildItemsBaseEnrichmentUpdate({
|
||||||
id: spriteId,
|
id: itemId,
|
||||||
dims,
|
dims,
|
||||||
cansiton: autoInteraction.canSit,
|
cansiton: autoInteraction.canSit,
|
||||||
canlayon: autoInteraction.canLay,
|
canlayon: autoInteraction.canLay,
|
||||||
@@ -947,7 +997,7 @@ export async function importSingleFurni(params: {
|
|||||||
interactionType,
|
interactionType,
|
||||||
interactionModesCount,
|
interactionModesCount,
|
||||||
});
|
});
|
||||||
await db.execute(update);
|
if (!preservingExisting) await db.execute(update);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`items_base dimension update failed: ${(err as Error).message}`,
|
`items_base dimension update failed: ${(err as Error).message}`,
|
||||||
@@ -986,16 +1036,16 @@ export async function importSingleFurni(params: {
|
|||||||
let catalogItemId: number | null = null;
|
let catalogItemId: number | null = null;
|
||||||
let catalogUpdated = false;
|
let catalogUpdated = false;
|
||||||
try {
|
try {
|
||||||
const itemIdStr = String(spriteId);
|
const itemIdStr = String(itemId);
|
||||||
|
|
||||||
// Check if this item already exists anywhere in the catalog
|
// Check if this item already exists anywhere in the catalog
|
||||||
const [existingCatalog] = (await db.execute(sql`
|
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];
|
`)) as unknown as [Array<{ id: number; page_id: string }>, unknown];
|
||||||
|
|
||||||
if (existingCatalog.length > 0) {
|
if (existingCatalog.length > 0) {
|
||||||
catalogItemId = existingCatalog[0].id;
|
catalogItemId = existingCatalog[0].id;
|
||||||
if (updateExisting) {
|
if (updateExisting && !preservingExisting) {
|
||||||
// Update existing catalog entry: price, page, etc.
|
// Update existing catalog entry: price, page, etc.
|
||||||
const categoryPageId = await getOrCreateCategoryPage(
|
const categoryPageId = await getOrCreateCategoryPage(
|
||||||
classname,
|
classname,
|
||||||
@@ -1039,7 +1089,7 @@ export async function importSingleFurni(params: {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
itemId: spriteId,
|
itemId,
|
||||||
catalogItemId,
|
catalogItemId,
|
||||||
catalogUpdated,
|
catalogUpdated,
|
||||||
dimensions: dims,
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { normalizeClassname } from "@/lib/furni/classname";
|
||||||
/**
|
/**
|
||||||
* Furniture search & listing — powered by official Habbo furnidata.
|
* 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 perPage = Math.min(options.perPage || 50, 100);
|
||||||
const page = options.page || 1;
|
const page = options.page || 1;
|
||||||
const search = options.search?.toLowerCase().trim() || "";
|
const search = options.search?.toLowerCase().trim() || "";
|
||||||
const importedSet = options.importedSet;
|
const importedSet = options.importedSet
|
||||||
|
? new Set([...options.importedSet].map(normalizeClassname))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const map = await getOfficialHabboFurnidata();
|
const map = await getOfficialHabboFurnidata();
|
||||||
@@ -58,8 +61,8 @@ export async function listFurniture(
|
|||||||
if (importedSet) {
|
if (importedSet) {
|
||||||
// Non-imported first (id desc), then imported (id desc).
|
// Non-imported first (id desc), then imported (id desc).
|
||||||
items.sort((a, b) => {
|
items.sort((a, b) => {
|
||||||
const aImp = importedSet.has(a.classname) ? 1 : 0;
|
const aImp = importedSet.has(normalizeClassname(a.classname)) ? 1 : 0;
|
||||||
const bImp = importedSet.has(b.classname) ? 1 : 0;
|
const bImp = importedSet.has(normalizeClassname(b.classname)) ? 1 : 0;
|
||||||
if (aImp !== bImp) return aImp - bImp;
|
if (aImp !== bImp) return aImp - bImp;
|
||||||
return b.id - a.id;
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("import/core/download validators", () => {
|
||||||
it("accepts FWS/CWS/ZWS swf magic", () => {
|
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);
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
|
import { parseNitroBundle } from "../../swf/nitro-builder";
|
||||||
|
|
||||||
import { browserHeaders } from "./browser-headers";
|
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(
|
export async function tryDownloadCandidates(
|
||||||
urls: string[],
|
urls: string[],
|
||||||
destPath: string,
|
destPath: string,
|
||||||
kind: "swf" | "png",
|
kind: "swf" | "png",
|
||||||
|
onFailure?: (detail: string) => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (const url of urls) {
|
for (const url of urls) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const res = await downloadFile(url, destPath, { validate: kind, signal });
|
const res = await downloadFile(url, destPath, { validate: kind, signal });
|
||||||
if (res.ok) return true;
|
if (res.ok) return true;
|
||||||
|
const source = new URL(url);
|
||||||
|
onFailure?.(
|
||||||
|
`${source.host}${source.pathname}: ${res.error ?? "download failed"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -37,14 +57,15 @@ export async function downloadFile(
|
|||||||
destPath: string,
|
destPath: string,
|
||||||
options?: {
|
options?: {
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
validate?: "swf" | "png";
|
validate?: "swf" | "png" | "nitro";
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
},
|
},
|
||||||
): Promise<{ ok: boolean; size: number }> {
|
): Promise<{ ok: boolean; size: number; error?: string }> {
|
||||||
const maxRetries = options?.maxRetries ?? 3;
|
const maxRetries = options?.maxRetries ?? 3;
|
||||||
const baseDelay = 1000;
|
const baseDelay = 1000;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
let stage: "download" | "write" = "download";
|
||||||
try {
|
try {
|
||||||
options?.signal?.throwIfAborted();
|
options?.signal?.throwIfAborted();
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
@@ -66,39 +87,54 @@ export async function downloadFile(
|
|||||||
console.warn(
|
console.warn(
|
||||||
`[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`,
|
`[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;
|
continue;
|
||||||
}
|
}
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
if (buffer.length < 8) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (options?.validate === "swf" && !validateSwfBytes(buffer)) {
|
if (options?.validate === "swf" && !validateSwfBytes(buffer)) {
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries) {
|
||||||
console.warn(`[import-download] Invalid SWF magic bytes from ${url}`);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (options?.validate === "png" && !validatePngBytes(buffer)) {
|
if (options?.validate === "png" && !validatePngBytes(buffer)) {
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries) {
|
||||||
console.warn(`[import-download] Invalid PNG magic bytes from ${url}`);
|
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;
|
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);
|
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
|
||||||
return { ok: true, size: buffer.length };
|
return { ok: true, size: buffer.length };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (options?.signal?.aborted) throw err;
|
if (options?.signal?.aborted) throw err;
|
||||||
if (attempt === maxRetries) {
|
if (attempt === maxRetries || stage === "write") {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[import-download] Download error ${url}:`,
|
`[import-download] Download error ${url}:`,
|
||||||
(err as Error).message,
|
(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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -154,8 +154,10 @@ export async function convertSwfToNitroAsync(
|
|||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
classname: string,
|
classname: string,
|
||||||
): Promise<ConversionResult> {
|
): Promise<ConversionResult> {
|
||||||
const res = await run({ type: "convert", buffer, classname });
|
const res = await run({ type: "convert", buffer, classname }).catch(
|
||||||
if (res.ok && res.result) return res.result;
|
() => null,
|
||||||
|
);
|
||||||
|
if (res?.ok && res.result) return res.result;
|
||||||
// Worker failed for this item — fall back to the main thread.
|
// Worker failed for this item — fall back to the main thread.
|
||||||
return convertSwfToNitro(buffer, classname);
|
return convertSwfToNitro(buffer, classname);
|
||||||
}
|
}
|
||||||
@@ -164,7 +166,7 @@ export async function extractIconFromSwfAsync(
|
|||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
classname: string,
|
classname: string,
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const res = await run({ type: "icon", buffer, classname });
|
const res = await run({ type: "icon", buffer, classname }).catch(() => null);
|
||||||
if (res.ok) return res.icon ?? null;
|
if (res?.ok) return res.icon ?? null;
|
||||||
return extractIconFromSwf(buffer, classname);
|
return extractIconFromSwf(buffer, classname);
|
||||||
}
|
}
|
||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -873,6 +873,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Menü durchsuchen…",
|
||||||
|
"clearMenuSearch": "Menüsuche löschen",
|
||||||
|
"noMenuResults": "Keine passenden Seiten.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -1058,6 +1058,9 @@
|
|||||||
"backToSite": "Back to site",
|
"backToSite": "Back to site",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"housekeeping": "Permission archive",
|
"housekeeping": "Permission archive",
|
||||||
"hotelManagementPanel": "Hotel management panel",
|
"hotelManagementPanel": "Hotel management panel",
|
||||||
"rankLabel": "Rank {rank}",
|
"rankLabel": "Rank {rank}",
|
||||||
|
|||||||
@@ -873,6 +873,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Buscar en el menú…",
|
||||||
|
"clearMenuSearch": "Borrar búsqueda",
|
||||||
|
"noMenuResults": "No se encontraron páginas.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -873,6 +873,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Rechercher dans le menu…",
|
||||||
|
"clearMenuSearch": "Effacer la recherche",
|
||||||
|
"noMenuResults": "Aucune page trouvée.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -1016,6 +1016,9 @@
|
|||||||
"backToSite": "Torna al sito",
|
"backToSite": "Torna al sito",
|
||||||
"expandAll": "Apri tutte",
|
"expandAll": "Apri tutte",
|
||||||
"collapseAll": "Chiudi tutte",
|
"collapseAll": "Chiudi tutte",
|
||||||
|
"searchMenu": "Cerca nel menu…",
|
||||||
|
"clearMenuSearch": "Cancella ricerca menu",
|
||||||
|
"noMenuResults": "Nessuna pagina trovata.",
|
||||||
"hotelManagementPanel": "Pannello di gestione hotel",
|
"hotelManagementPanel": "Pannello di gestione hotel",
|
||||||
"rankLabel": "Grado {rank}",
|
"rankLabel": "Grado {rank}",
|
||||||
"hubNewsMedia": "Notizie e media",
|
"hubNewsMedia": "Notizie e media",
|
||||||
|
|||||||
@@ -1047,6 +1047,9 @@
|
|||||||
"backToSite": "Terug naar site",
|
"backToSite": "Terug naar site",
|
||||||
"expandAll": "Alles openen",
|
"expandAll": "Alles openen",
|
||||||
"collapseAll": "Alles sluiten",
|
"collapseAll": "Alles sluiten",
|
||||||
|
"searchMenu": "Zoeken in menu…",
|
||||||
|
"clearMenuSearch": "Menuzoekopdracht wissen",
|
||||||
|
"noMenuResults": "Geen pagina’s gevonden.",
|
||||||
"housekeeping": "Permissie-archief",
|
"housekeeping": "Permissie-archief",
|
||||||
"hotelManagementPanel": "Hotelbeheerpaneel",
|
"hotelManagementPanel": "Hotelbeheerpaneel",
|
||||||
"rankLabel": "Rang {rank}",
|
"rankLabel": "Rang {rank}",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -901,6 +901,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
@@ -903,6 +903,9 @@
|
|||||||
"marketplace": "Marketplace",
|
"marketplace": "Marketplace",
|
||||||
"expandAll": "Expand all",
|
"expandAll": "Expand all",
|
||||||
"collapseAll": "Collapse all",
|
"collapseAll": "Collapse all",
|
||||||
|
"searchMenu": "Search menu…",
|
||||||
|
"clearMenuSearch": "Clear menu search",
|
||||||
|
"noMenuResults": "No matching pages.",
|
||||||
"community": "Community",
|
"community": "Community",
|
||||||
"monitoring": "Monitoring",
|
"monitoring": "Monitoring",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
|
|||||||
Reference in new issue
Block a user