From 85b5792d4047f64e1111638dd31b29bdb95a3671 Mon Sep 17 00:00:00 2001 From: openhands Date: Sat, 22 Aug 2026 14:28:06 +0200 Subject: [PATCH] feat: RCON API transport, full furnidata translation with LibreTranslate fallback, remove organize feature, bump per-page limit to 500 - Add EMULATOR_MODE=api option with HTTP transport mirroring RCON payload - buildLocalizedFurniDataFiles now falls back to LibreTranslate for customs (incl. pl/ru/ar/ja) - Remove Organiseer import button from studio and Reorganize Catalog from import page - Delete organize API route and PUT handler; remove organizeCatalogAll from catalog-repair.ts - Increase imported-furni list perPage from 50 to 500 --- .../import/furni/import-furni-client.tsx | 141 --------- .../api/admin/import/furni/organize/route.ts | 42 --- src/app/api/admin/import/furni/route.ts | 284 +----------------- src/components/admin/studio/studio-client.tsx | 47 --- src/env.ts | 11 + src/lib/services/catalog-repair.ts | 245 --------------- src/lib/services/furni-data-i18n.ts | 42 ++- src/lib/services/rcon.ts | 102 ++++++- 8 files changed, 146 insertions(+), 768 deletions(-) delete mode 100644 src/app/api/admin/import/furni/organize/route.ts diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx index 4ed5349c..8ddf48f8 100644 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ b/src/app/admin/import/furni/import-furni-client.tsx @@ -10,7 +10,6 @@ import { Download, ExternalLink, FileWarning, - FolderSync, Globe2, ImageDown, LayoutGrid, @@ -141,8 +140,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { // Nitro editor const [editingClassname, setEditingClassname] = useState(null); - // Reorganize catalog - const [reorganizing, setReorganizing] = useState(false); const [confirmRecentResync, setConfirmRecentResync] = useState(false); const [recentResyncing, setRecentResyncing] = useState(false); const [recentResyncResult, setRecentResyncResult] = @@ -157,13 +154,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { // Confirmation dialogs const [confirmBatch, setConfirmBatch] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); - const [reorgPreview, setReorgPreview] = useState<{ - moved: number; - repriced: number; - skipped: number; - total: number; - categoryCounts: Record; - } | null>(null); const fetchStats = useCallback(async () => { try { @@ -699,31 +689,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { fetchStats(); } - async function startReorganize() { - setReorganizing(true); - try { - const previewRes = await adminFetch("/api/admin/import/furni?dryrun=1", { - method: "PUT", - }); - const previewData = await previewRes.json(); - if (!previewRes.ok) { - toast.error(previewData.error || "Preview failed"); - return; - } - if (previewData.moved === 0 && previewData.repriced === 0) { - toast.info( - previewData.message || "Everything is already correctly organized", - ); - return; - } - setReorgPreview(previewData); - } catch { - toast.error("Network error"); - } finally { - setReorganizing(false); - } - } - async function resyncRecentImports() { setConfirmRecentResync(false); setRecentResyncing(true); @@ -809,32 +774,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { } } - async function applyReorganize() { - setReorgPreview(null); - setReorganizing(true); - try { - const res = await adminFetch("/api/admin/import/furni", { - method: "PUT", - }); - const d = await res.json(); - if (res.ok) { - const parts: string[] = []; - if (d.moved > 0) parts.push(`${d.moved} moved`); - if (d.repriced > 0) parts.push(`${d.repriced} repriced`); - if (d.skipped > 0) parts.push(`${d.skipped} already ok`); - if (d.pagesDeleted > 0) - parts.push(`${d.pagesDeleted} empty pages cleaned`); - toast.success(`Done! ${parts.join(", ")} (${d.total} total)`); - } else { - toast.error(d.error || "Failed"); - } - } catch { - toast.error("Network error"); - } finally { - setReorganizing(false); - } - } - async function doDeleteItem(item: FurniItem) { setConfirmDelete(null); setDeletingItem(item.classname); @@ -1581,10 +1520,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { ? "Updating imported furni..." : "Update imported furni"} - - - {reorganizing ? "Reorganizing..." : "Reorganize Catalog"} - {addingSize32 ? "Adding size 32..." : "Add size 32 to all .nitro"} @@ -2082,82 +2017,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) { - - {/* Reorganize Preview */} - setReorgPreview(null)}> - - - Reorganize Catalog - - Preview of changes to {reorgPreview?.total} imported items: - - - {reorgPreview && ( -
-
- {reorgPreview.moved > 0 && ( -
-

- {reorgPreview.moved} -

-

- Items to move -

-
- )} - {reorgPreview.repriced > 0 && ( -
-

- {reorgPreview.repriced} -

-

- Prices to update -

-
- )} - {reorgPreview.skipped > 0 && ( -
-

- {reorgPreview.skipped} -

-

- Already correct -

-
- )} -
- {Object.keys(reorgPreview.categoryCounts || {}).length > 0 && ( -
-

- Categories: -

-
- {Object.entries(reorgPreview.categoryCounts) - .sort(([, a], [, b]) => b - a) - .map(([cat, count]) => ( - - {cat}: {count} - - ))} -
-
- )} -
- )} - - - - -
-
); } diff --git a/src/app/api/admin/import/furni/organize/route.ts b/src/app/api/admin/import/furni/organize/route.ts deleted file mode 100644 index 0e288951..00000000 --- a/src/app/api/admin/import/furni/organize/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { withAdmin } from "@/lib/api-handler"; -import { apiError, apiOk } from "@/lib/api-response"; -import { PERMS } from "@/lib/permissions"; -import { logAudit } from "@/lib/services/audit"; -import { organizeCatalogAll } from "@/lib/services/catalog-repair"; -import { rcon } from "@/lib/services/rcon"; - -// ── Fully automatic catalog organization of the whole database ────── -export const POST = withAdmin( - { permission: PERMS.ASSETS_IMPORT }, - async (_request, ctx) => { - try { - const result = await organizeCatalogAll(); - - // Refresh game server caches so the reorganized catalog shows up. - try { - await rcon.updateCatalog(); - } catch (err) { - console.warn( - "[organize-furni] RCON catalog refresh failed:", - (err as Error).message, - ); - } - - logAudit({ - userId: ctx.session.user.id, - action: "furni_organize", - target: "CatalogItems", - targetId: 0, - after: { - pagesCreated: result.pagesCreated, - catalogAdded: result.catalogAdded, - catalogRehomed: result.catalogRehomed, - }, - }); - - return apiOk({ ...result }); - } catch (err) { - return apiError((err as Error).message, 500); - } - }, -); diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 4ba8879b..9a66b4d2 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -1,9 +1,9 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { withAdmin } from "@/lib/api-handler"; import { apiError, apiOk } from "@/lib/api-response"; -import { CatalogPages, db, ItemsBase } from "@/lib/db"; +import { db, ItemsBase } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { logAudit } from "@/lib/services/audit"; import { getCloneList } from "@/lib/services/clone-import"; @@ -20,19 +20,14 @@ import { patchLocalizedFurniDataEntries, } from "@/lib/services/furni-data-i18n"; import { - autoPriceFurni, - CATEGORY_PAGE, - classifyFurni, cleanupConvertedSwfs, deleteImportedItem, downloadFile, ensureDirectories, ensureFurniOwnership, fixDatabaseConsistencyAfterImport, - IMPORTED_PAGE_CAPTION_SAVE, importSingleFurni, reconcileImportedOfferIds, - resetCatalogPageCache, syncAssetsToGamedataBundle, verifyAndFixInteractionModesCount, } from "@/lib/services/furni-import"; @@ -234,7 +229,7 @@ export const GET = withAdmin( request.nextUrl.searchParams.get("page") || "1", 10, ); - const perPage = 50; + const perPage = 500; const [importedItems] = (await db.execute(sql` SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib @@ -310,7 +305,7 @@ export const GET = withAdmin( source, search, page, - perPage: 50, + perPage: 500, filter: "all", }); @@ -371,7 +366,7 @@ export const GET = withAdmin( const result = await listFurniture({ search: search || undefined, page, - perPage: 50, + perPage: 500, importedSet, }); @@ -862,272 +857,3 @@ export const PATCH = withAdmin( return apiOk({ succeeded, failed, results }); }, ); - -// ── Re-classify ALL imported items across all sub-pages + update prices (PUT) ── -// Supports ?dryrun=1 to preview changes without applying them. -export const PUT = withAdmin( - { permission: PERMS.ASSETS_IMPORT }, - async (request, ctx) => { - const isDryRun = request.nextUrl.searchParams.get("dryrun") === "1"; - - // 1. Find the root "imported_furni" page - const [parentPage] = await db - .select({ id: CatalogPages.id }) - .from(CatalogPages) - .where(eq(CatalogPages.captionSave, IMPORTED_PAGE_CAPTION_SAVE)) - .limit(1); - if (!parentPage) { - return apiOk({ - dryRun: isDryRun, - moved: 0, - repriced: 0, - message: "No imported_furni page found — nothing to reorganize", - }); - } - - // 2. Collect ALL imported page IDs (parent + every imp_* sub-page) - const subPages = await db - .select({ - id: CatalogPages.id, - captionSave: CatalogPages.captionSave, - }) - .from(CatalogPages) - .where(eq(CatalogPages.parentId, parentPage.id)); - const allImportedPageIds = [ - String(parentPage.id), - ...subPages.map((p) => String(p.id)), - ]; - - // 3. Find ALL catalog_items across all imported pages - // page_id is VARCHAR in DB so we use string placeholders - const [allItems] = (await db.execute(sql` - SELECT id, item_ids, catalog_name, page_id, cost_credits - FROM catalog_items - WHERE CAST(page_id AS CHAR) IN (${sql.join( - allImportedPageIds.map((id) => sql`${id}`), - sql`, `, - )}) - `)) as unknown as [ - Array<{ - id: number; - item_ids: string; - catalog_name: string; - page_id: string; - cost_credits: number; - }>, - unknown, - ]; - - if (allItems.length === 0) { - return apiOk({ - dryRun: isDryRun, - moved: 0, - repriced: 0, - message: "No imported items found — nothing to reorganize", - }); - } - - // 4. Build classname → type map from items_base - const uniqueItemIds = [ - ...new Set(allItems.map((i) => Number(i.item_ids))), - ].filter((n) => !Number.isNaN(n)); - const dbItems = - uniqueItemIds.length > 0 - ? await db - .select({ - id: ItemsBase.id, - itemName: ItemsBase.itemName, - type: ItemsBase.type, - }) - .from(ItemsBase) - .where(inArray(ItemsBase.id, uniqueItemIds)) - : []; - const typeMap = new Map(); - for (const di of dbItems) { - if (di.itemName) typeMap.set(di.itemName, di.type || "s"); - } - - // 5. Pre-load category page map (captionSave → pageId) to avoid N+1 queries - const categoryPageMap = new Map(); - for (const sp of subPages) { - if (sp.captionSave) categoryPageMap.set(sp.captionSave, sp.id); - } - - // Helper: resolve or create category page using the cached map - async function resolvePageId(catKey: string): Promise { - const captionSave = `imp_${catKey}`; - const cached = categoryPageMap.get(captionSave); - if (cached !== undefined) return String(cached); - - if (isDryRun) return "new"; - - // Create the page (only happens once per new category) - const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other; - const parentId = parentPage.id; - await db.execute(sql` - INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser) - VALUES (${captionSave}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '') - `); - const [created] = await db - .select({ id: CatalogPages.id }) - .from(CatalogPages) - .where( - and( - eq(CatalogPages.captionSave, captionSave), - eq(CatalogPages.parentId, parentId), - ), - ) - .limit(1); - categoryPageMap.set(captionSave, created?.id); - return String(created?.id); - } - - // 6. Classify all items (pure computation + cached lookups, no per-item DB queries) - let moved = 0; - let repriced = 0; - let skipped = 0; - const errors: string[] = []; - const categoryCounts: Record = {}; - const pagesWithItems = new Set(); - - // Collect batched updates: key = "pageId|credits|points|pointsType" → item ids - const updateBatches = new Map(); - - // First pass: resolve any new category pages (max ~14 DB inserts) - const neededCategories = new Set(); - for (const item of allItems) { - const itemType = typeMap.get(item.catalog_name) || "s"; - neededCategories.add(classifyFurni(item.catalog_name, itemType)); - } - for (const catKey of neededCategories) { - try { - await resolvePageId(catKey); - } catch (err) { - errors.push(`create page ${catKey}: ${(err as Error).message}`); - } - } - - // Second pass: classify items (no DB calls) - for (const item of allItems) { - const itemType = typeMap.get(item.catalog_name) || "s"; - const catKey = classifyFurni(item.catalog_name, itemType); - categoryCounts[catKey] = (categoryCounts[catKey] || 0) + 1; - - const captionSave = `imp_${catKey}`; - const cachedPageId = categoryPageMap.get(captionSave); - const correctPageIdStr = - cachedPageId !== undefined ? String(cachedPageId) : "new"; - - const price = autoPriceFurni(item.catalog_name); - const needsMove = item.page_id !== correctPageIdStr; - const needsReprice = item.cost_credits !== price.credits; - - pagesWithItems.add(correctPageIdStr); - - if (!needsMove && !needsReprice) { - skipped++; - continue; - } - - if (needsMove) moved++; - if (needsReprice) repriced++; - - if (!isDryRun) { - const batchKey = `${correctPageIdStr}|${price.credits}|${price.points}|${price.pointsType}`; - const batch = updateBatches.get(batchKey); - if (batch) { - batch.push(item.id); - } else { - updateBatches.set(batchKey, [item.id]); - } - } - } - - // 7. Apply batched updates (one UPDATE per category/price combo instead of per item) - if (!isDryRun) { - for (const [batchKey, ids] of updateBatches) { - const [pageId, credits, points, pointsType] = batchKey.split("|"); - try { - await db.execute(sql` - UPDATE catalog_items - SET page_id = ${pageId}, - cost_credits = ${Number(credits)}, - cost_points = ${Number(points)}, - points_type = ${Number(pointsType)} - WHERE id IN (${sql.join( - ids.map((id) => sql`${id}`), - sql`, `, - )}) - `); - } catch (err) { - errors.push(`batch update: ${(err as Error).message}`); - } - } - } - - // 8. Cleanup empty sub-pages (only if NOT dry-run) - let pagesDeleted = 0; - if (!isDryRun) { - for (const subPage of subPages) { - const subPageIdStr = String(subPage.id); - if (!pagesWithItems.has(subPageIdStr)) { - try { - const [remaining] = (await db.execute( - sql`SELECT COUNT(*) as cnt FROM catalog_items WHERE CAST(page_id AS CHAR) = ${subPageIdStr}`, - )) as unknown as [Array<{ cnt: bigint }>, unknown]; - const count = Number(remaining[0]?.cnt ?? 0); - if (count === 0) { - await db.execute( - sql`DELETE FROM catalog_pages WHERE id = ${subPage.id}`, - ); - pagesDeleted++; - } - } catch (err) { - errors.push( - `cleanup ${subPage.captionSave}: ${(err as Error).message}`, - ); - } - } - } - } - - // 9. Refresh game server caches (only if NOT dry-run and changes were made) - if (!isDryRun && (moved > 0 || repriced > 0 || pagesDeleted > 0)) { - try { - await rcon.updateCatalog(); - } catch (err) { - errors.push(`RCON: ${(err as Error).message}`); - } - } - - if (!isDryRun) { - // Clear in-memory catalog page cache since we may have created/deleted pages - resetCatalogPageCache(); - logAudit({ - userId: ctx.session.user.id, - action: "furni_reorganize", - target: "CatalogItems", - targetId: parentPage.id, - after: { - moved, - repriced, - skipped, - pagesDeleted, - categoryCounts, - errors: errors.length > 0 ? errors : undefined, - }, - }); - } - - return apiOk({ - dryRun: isDryRun, - moved, - repriced, - skipped, - pagesDeleted, - total: allItems.length, - categoryCounts, - errors: errors.length > 0 ? errors : undefined, - }); - }, -); diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index 4e77a1af..7c2daaa6 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -514,37 +514,6 @@ export function StudioClient({ } }; - const [organizing, setOrganizing] = useState(false); - - async function organizeAll() { - if (organizing) return; - setOrganizing(true); - try { - const res = await adminFetch("/api/admin/import/furni/organize", { - method: "POST", - }); - const data = await res.json(); - if (res.ok) { - const parts = [ - `Catalogus georganiseerd`, - data.pagesCreated > 0 - ? `${data.pagesCreated} pagina's aangemaakt` - : null, - data.catalogAdded > 0 ? `${data.catalogAdded} nieuw` : null, - data.catalogRehomed > 0 ? `${data.catalogRehomed} bijgewerkt` : null, - ].filter(Boolean); - toast.success(parts.join(" · ")); - fetchStats(); - } else { - toast.error(data.error || "Organiseren mislukt"); - } - } catch { - toast.error("Netwerkfout"); - } finally { - setOrganizing(false); - } - } - async function deleteItem() { if (!deleting) return; try { @@ -875,22 +844,6 @@ export function StudioClient({
- - {selectedNonImported > 0 && (