feat: RCON API transport, full furnidata translation with LibreTranslate fallback, remove organize feature, bump per-page limit to 500
CI / check (push) Failing after 7s
CI / release (push) Skipped
CI / deploy (push) Skipped

- 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
This commit is contained in:
openhands committed 2026-08-22 14:28:06 +02:00
1 parent 5668493485
commit 85b5792d40
8 files changed
+146 -768

No files matched your search

@@ -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);
}
},
);
+5 -279
View File
@@ -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<string, string>();
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<string, number | undefined>();
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<string> {
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<string, number> = {};
const pagesWithItems = new Set<string>();
// Collect batched updates: key = "pageId|credits|points|pointsType" → item ids
const updateBatches = new Map<string, number[]>();
// First pass: resolve any new category pages (max ~14 DB inserts)
const neededCategories = new Set<string>();
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,
});
},
);