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
This commit is contained in:
1 parent
5668493485
commit
85b5792d40
8 files changed
+146
-768
No files matched your search
@@ -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<string | null>(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<FurniItem | null>(null);
|
||||
const [reorgPreview, setReorgPreview] = useState<{
|
||||
moved: number;
|
||||
repriced: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
categoryCounts: Record<string, number>;
|
||||
} | 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"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={startReorganize} disabled={reorganizing}>
|
||||
<FolderSync className="w-4 h-4 mr-2" />
|
||||
{reorganizing ? "Reorganizing..." : "Reorganize Catalog"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={addSize32All} disabled={addingSize32}>
|
||||
<ImageDown className="w-4 h-4 mr-2" />
|
||||
{addingSize32 ? "Adding size 32..." : "Add size 32 to all .nitro"}
|
||||
@@ -2082,82 +2017,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reorganize Preview */}
|
||||
<Dialog open={!!reorgPreview} onOpenChange={() => setReorgPreview(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reorganize Catalog</DialogTitle>
|
||||
<DialogDescription>
|
||||
Preview of changes to {reorgPreview?.total} imported items:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{reorgPreview && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{reorgPreview.moved > 0 && (
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{reorgPreview.moved}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Items to move
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{reorgPreview.repriced > 0 && (
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-lg font-bold text-[var(--admin-warning)]">
|
||||
{reorgPreview.repriced}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Prices to update
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{reorgPreview.skipped > 0 && (
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-lg font-bold text-muted-foreground">
|
||||
{reorgPreview.skipped}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Already correct
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{Object.keys(reorgPreview.categoryCounts || {}).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Categories:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(reorgPreview.categoryCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([cat, count]) => (
|
||||
<Badge
|
||||
key={cat}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{cat}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReorgPreview(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={applyReorganize}>
|
||||
<FolderSync className="w-4 h-4 mr-1" /> Apply Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user