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
+144
-766
No files matched your search
@@ -10,7 +10,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileWarning,
|
FileWarning,
|
||||||
FolderSync,
|
|
||||||
Globe2,
|
Globe2,
|
||||||
ImageDown,
|
ImageDown,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -141,8 +140,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
|||||||
// Nitro editor
|
// Nitro editor
|
||||||
const [editingClassname, setEditingClassname] = useState<string | null>(null);
|
const [editingClassname, setEditingClassname] = useState<string | null>(null);
|
||||||
|
|
||||||
// Reorganize catalog
|
|
||||||
const [reorganizing, setReorganizing] = useState(false);
|
|
||||||
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
|
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
|
||||||
const [recentResyncing, setRecentResyncing] = useState(false);
|
const [recentResyncing, setRecentResyncing] = useState(false);
|
||||||
const [recentResyncResult, setRecentResyncResult] =
|
const [recentResyncResult, setRecentResyncResult] =
|
||||||
@@ -157,13 +154,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
|||||||
// Confirmation dialogs
|
// Confirmation dialogs
|
||||||
const [confirmBatch, setConfirmBatch] = useState(false);
|
const [confirmBatch, setConfirmBatch] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState<FurniItem | null>(null);
|
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 () => {
|
const fetchStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -699,31 +689,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
|||||||
fetchStats();
|
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() {
|
async function resyncRecentImports() {
|
||||||
setConfirmRecentResync(false);
|
setConfirmRecentResync(false);
|
||||||
setRecentResyncing(true);
|
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) {
|
async function doDeleteItem(item: FurniItem) {
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
setDeletingItem(item.classname);
|
setDeletingItem(item.classname);
|
||||||
@@ -1581,10 +1520,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
|||||||
? "Updating imported furni..."
|
? "Updating imported furni..."
|
||||||
: "Update imported furni"}
|
: "Update imported furni"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={startReorganize} disabled={reorganizing}>
|
|
||||||
<FolderSync className="w-4 h-4 mr-2" />
|
|
||||||
{reorganizing ? "Reorganizing..." : "Reorganize Catalog"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={addSize32All} disabled={addingSize32}>
|
<DropdownMenuItem onClick={addSize32All} disabled={addingSize32}>
|
||||||
<ImageDown className="w-4 h-4 mr-2" />
|
<ImageDown className="w-4 h-4 mr-2" />
|
||||||
{addingSize32 ? "Adding size 32..." : "Add size 32 to all .nitro"}
|
{addingSize32 ? "Adding size 32..." : "Add size 32 to all .nitro"}
|
||||||
@@ -2082,82 +2017,6 @@ export function ImportFurniClient({ source }: { source: FurniImportSource }) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</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 { existsSync, promises as fs } from "node:fs";
|
||||||
import path from "node:path";
|
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 { withAdmin } from "@/lib/api-handler";
|
||||||
import { apiError, apiOk } from "@/lib/api-response";
|
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 { PERMS } from "@/lib/permissions";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
import { logAudit } from "@/lib/services/audit";
|
||||||
import { getCloneList } from "@/lib/services/clone-import";
|
import { getCloneList } from "@/lib/services/clone-import";
|
||||||
@@ -20,19 +20,14 @@ import {
|
|||||||
patchLocalizedFurniDataEntries,
|
patchLocalizedFurniDataEntries,
|
||||||
} from "@/lib/services/furni-data-i18n";
|
} from "@/lib/services/furni-data-i18n";
|
||||||
import {
|
import {
|
||||||
autoPriceFurni,
|
|
||||||
CATEGORY_PAGE,
|
|
||||||
classifyFurni,
|
|
||||||
cleanupConvertedSwfs,
|
cleanupConvertedSwfs,
|
||||||
deleteImportedItem,
|
deleteImportedItem,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
ensureDirectories,
|
ensureDirectories,
|
||||||
ensureFurniOwnership,
|
ensureFurniOwnership,
|
||||||
fixDatabaseConsistencyAfterImport,
|
fixDatabaseConsistencyAfterImport,
|
||||||
IMPORTED_PAGE_CAPTION_SAVE,
|
|
||||||
importSingleFurni,
|
importSingleFurni,
|
||||||
reconcileImportedOfferIds,
|
reconcileImportedOfferIds,
|
||||||
resetCatalogPageCache,
|
|
||||||
syncAssetsToGamedataBundle,
|
syncAssetsToGamedataBundle,
|
||||||
verifyAndFixInteractionModesCount,
|
verifyAndFixInteractionModesCount,
|
||||||
} from "@/lib/services/furni-import";
|
} from "@/lib/services/furni-import";
|
||||||
@@ -234,7 +229,7 @@ export const GET = withAdmin(
|
|||||||
request.nextUrl.searchParams.get("page") || "1",
|
request.nextUrl.searchParams.get("page") || "1",
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
const perPage = 50;
|
const perPage = 500;
|
||||||
|
|
||||||
const [importedItems] = (await db.execute(sql`
|
const [importedItems] = (await db.execute(sql`
|
||||||
SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib
|
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,
|
source,
|
||||||
search,
|
search,
|
||||||
page,
|
page,
|
||||||
perPage: 50,
|
perPage: 500,
|
||||||
filter: "all",
|
filter: "all",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -371,7 +366,7 @@ export const GET = withAdmin(
|
|||||||
const result = await listFurniture({
|
const result = await listFurniture({
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
page,
|
page,
|
||||||
perPage: 50,
|
perPage: 500,
|
||||||
importedSet,
|
importedSet,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -862,272 +857,3 @@ export const PATCH = withAdmin(
|
|||||||
return apiOk({ succeeded, failed, results });
|
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -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() {
|
async function deleteItem() {
|
||||||
if (!deleting) return;
|
if (!deleting) return;
|
||||||
try {
|
try {
|
||||||
@@ -875,22 +844,6 @@ export function StudioClient({
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="gap-1.5"
|
|
||||||
disabled={organizing || !!batchProgress}
|
|
||||||
onClick={organizeAll}
|
|
||||||
title="Alle meubels uit de Import-sectie automatisch ordenen in catalogus-pagina's (pagina's worden aangemaakt indien nodig)"
|
|
||||||
>
|
|
||||||
{organizing ? (
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<WandSparkles size={14} />
|
|
||||||
)}
|
|
||||||
Organiseer import
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{selectedNonImported > 0 && (
|
{selectedNonImported > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
+11
@@ -45,6 +45,17 @@ const schema = z
|
|||||||
RCON_PORT: z.coerce.number().int().positive().default(3001),
|
RCON_PORT: z.coerce.number().int().positive().default(3001),
|
||||||
RCON_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000),
|
RCON_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000),
|
||||||
RCON_MAX_RETRIES: z.coerce.number().int().positive().default(3),
|
RCON_MAX_RETRIES: z.coerce.number().int().positive().default(3),
|
||||||
|
// Emulator transport selection. "rcon" uses the raw-JSON TCP protocol
|
||||||
|
// above; "api" uses an HTTP endpoint that accepts the same
|
||||||
|
// `{"key":...,"data":...}` payload (for emulators without RCON).
|
||||||
|
EMULATOR_MODE: z.enum(["rcon", "api"]).default("rcon"),
|
||||||
|
// HTTP endpoint used when EMULATOR_MODE=api. Required in that mode.
|
||||||
|
EMULATOR_API_URL: z.string().url().optional(),
|
||||||
|
// Optional bearer/key auth for the API transport. When set, it is sent
|
||||||
|
// as `${EMULATOR_API_KEY_HEADER}: ${EMULATOR_API_KEY}` (default header
|
||||||
|
// "Authorization").
|
||||||
|
EMULATOR_API_KEY: z.string().optional(),
|
||||||
|
EMULATOR_API_KEY_HEADER: z.string().default("Authorization"),
|
||||||
// NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing.
|
// NextAuth v5 reads AUTH_SECRET itself; declared here for documentation/typing.
|
||||||
AUTH_SECRET: z.string().min(1).optional(),
|
AUTH_SECRET: z.string().min(1).optional(),
|
||||||
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
|
// Laravel APP_KEY (base64:...) — needed to read existing 2FA secrets.
|
||||||
|
|||||||
@@ -11,12 +11,7 @@ import {
|
|||||||
classifyFurni,
|
classifyFurni,
|
||||||
getOrCreateCategoryPage,
|
getOrCreateCategoryPage,
|
||||||
getOrCreateImportedParentPage,
|
getOrCreateImportedParentPage,
|
||||||
resetCatalogPageCache,
|
|
||||||
} from "@/lib/services/furni-import";
|
} from "@/lib/services/furni-import";
|
||||||
import {
|
|
||||||
classifyCatalogItem,
|
|
||||||
NO_CATALOG_KINDS,
|
|
||||||
} from "@/lib/services/item-kind";
|
|
||||||
|
|
||||||
export interface MissingCatalogEntry {
|
export interface MissingCatalogEntry {
|
||||||
classname: string;
|
classname: string;
|
||||||
@@ -452,246 +447,6 @@ export async function applyCatalogSql(
|
|||||||
return { applied, failed };
|
return { applied, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizeCatalogResult {
|
|
||||||
/** Number of catalog sub-pages created under "Imported Furniture". */
|
|
||||||
pagesCreated: number;
|
|
||||||
/** Number of items that got a brand new catalog_items entry. */
|
|
||||||
catalogAdded: number;
|
|
||||||
/** Number of existing catalog entries re-homed/re-priced onto their correct page. */
|
|
||||||
catalogRehomed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fully automatic one-shot organization of everything that already lives under
|
|
||||||
* the "Imported Furniture" section of the catalog. For each imported item it:
|
|
||||||
* 1. ensures the correct target sub-page exists (creating it if missing).
|
|
||||||
* The target is the item's furniture line (e.g. "weebz", "habbox") when
|
|
||||||
* known and large enough, otherwise its auto-detected category
|
|
||||||
* (e.g. "Room & Building", "Rares"),
|
|
||||||
* 2. re-homes and re-prices any existing catalog entry so it sits on its
|
|
||||||
* correct sub-page with the canonical auto-detected price.
|
|
||||||
*
|
|
||||||
* Only items already present in the Imported section are touched — no new
|
|
||||||
* items are pulled in from `items_base`. No manual selection required.
|
|
||||||
*/
|
|
||||||
export async function organizeCatalogAll(): Promise<OrganizeCatalogResult> {
|
|
||||||
// 1) Parent page (sub-pages are created on demand below).
|
|
||||||
const parentPageId = await getOrCreateImportedParentPage();
|
|
||||||
|
|
||||||
// Furniture line (brand/series) per classname, sourced from FurnitureData.json
|
|
||||||
// since `items_base` does not store it. Used to give each line its own page.
|
|
||||||
const furniData = (await readFurniData()) as Record<
|
|
||||||
string,
|
|
||||||
{ furnitype?: Array<{ classname?: string; furniline?: string }> }
|
|
||||||
>;
|
|
||||||
const furnilineByClass = new Map<string, string>();
|
|
||||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
|
||||||
for (const e of furniData[section]?.furnitype ?? []) {
|
|
||||||
const cn = typeof e.classname === "string" ? e.classname : "";
|
|
||||||
const fl = typeof e.furniline === "string" ? e.furniline.trim() : "";
|
|
||||||
if (!cn || !fl) continue;
|
|
||||||
furnilineByClass.set(cn, fl);
|
|
||||||
const base = cn.split("*")[0];
|
|
||||||
if (base && base !== cn) furnilineByClass.set(base, fl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const resolveFurniline = (classname: string): string => {
|
|
||||||
const direct = furnilineByClass.get(classname);
|
|
||||||
if (direct) return direct;
|
|
||||||
const base = classname.split("*")[0];
|
|
||||||
return (base && furnilineByClass.get(base)) || "";
|
|
||||||
};
|
|
||||||
// The `caption_save` column is varchar(25). The line prefix `imp_line_`
|
|
||||||
// takes 9 chars, so the slug may be at most 16 chars to stay within limit.
|
|
||||||
const slug = (value: string): string =>
|
|
||||||
value
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "_")
|
|
||||||
.replace(/^_+|_+$/g, "")
|
|
||||||
.slice(0, 16);
|
|
||||||
|
|
||||||
// Minimum number of items a furniture line must have before it gets its own
|
|
||||||
// dedicated catalog page. Lines below this threshold (one-off customs, etc.)
|
|
||||||
// collapse onto the category page so we don't create hundreds of tiny pages.
|
|
||||||
const LINE_PAGE_MIN_ITEMS = 3;
|
|
||||||
|
|
||||||
// Ensure a sub-page exists under the imported parent and return its id.
|
|
||||||
const ensureSubPage = async (
|
|
||||||
captionSave: string,
|
|
||||||
caption: string,
|
|
||||||
): Promise<number> => {
|
|
||||||
const [existing] = (await db.execute(sql`
|
|
||||||
SELECT id FROM catalog_pages
|
|
||||||
WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId}
|
|
||||||
`)) as unknown as [Array<{ id: number }>, unknown];
|
|
||||||
if (existing?.[0]?.id) return existing[0].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}, ${caption}, 'default_3x3', ${parentPageId}, '1', 50, 263, '1', '1', '', '', '', '', '', '', '', '')
|
|
||||||
`);
|
|
||||||
const [created] = (await db.execute(sql`
|
|
||||||
SELECT id FROM catalog_pages
|
|
||||||
WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId}
|
|
||||||
`)) as unknown as [Array<{ id: number }>, unknown];
|
|
||||||
return created?.[0]?.id ?? existing?.[0]?.id ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sub-pages that already exist, so we can count the ones we create.
|
|
||||||
const [existingPages] = (await db.execute(sql`
|
|
||||||
SELECT caption_save FROM catalog_pages
|
|
||||||
WHERE parent_id = ${parentPageId} AND caption_save LIKE 'imp\\_%'
|
|
||||||
`)) as unknown as [Array<{ caption_save: string }>, unknown];
|
|
||||||
const existingCaptions = new Set(existingPages.map((r) => r.caption_save));
|
|
||||||
const createdCaptions = new Set<string>();
|
|
||||||
let pagesCreated = 0;
|
|
||||||
|
|
||||||
// 2) Inventory of items_base + current catalog state.
|
|
||||||
const [items] = (await db.execute(sql`
|
|
||||||
SELECT id, item_name, public_name, type, interaction_type
|
|
||||||
FROM items_base ORDER BY id
|
|
||||||
`)) as unknown as [
|
|
||||||
Array<{
|
|
||||||
id: number;
|
|
||||||
item_name: string;
|
|
||||||
public_name: string;
|
|
||||||
type: string;
|
|
||||||
interaction_type: string | null;
|
|
||||||
}>,
|
|
||||||
unknown,
|
|
||||||
];
|
|
||||||
|
|
||||||
const [catalogRows] = (await db.execute(sql`
|
|
||||||
SELECT id, item_ids, page_id, cost_credits FROM catalog_items
|
|
||||||
`)) as unknown as [
|
|
||||||
Array<{
|
|
||||||
id: number;
|
|
||||||
item_ids: string;
|
|
||||||
page_id: number | string;
|
|
||||||
cost_credits: number;
|
|
||||||
}>,
|
|
||||||
unknown,
|
|
||||||
];
|
|
||||||
|
|
||||||
const catalogPageByItem = new Map<number, number>();
|
|
||||||
const catalogCreditsByItem = new Map<number, number>();
|
|
||||||
for (const row of catalogRows) {
|
|
||||||
const ids = (row.item_ids ?? "")
|
|
||||||
.split(";")
|
|
||||||
.map((s) => Number(s.trim()))
|
|
||||||
.filter(Boolean);
|
|
||||||
for (const id of ids) {
|
|
||||||
if (!catalogPageByItem.has(id)) {
|
|
||||||
catalogPageByItem.set(id, Number(row.page_id));
|
|
||||||
catalogCreditsByItem.set(id, Number(row.cost_credits));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const [validPages] = (await db.execute(sql`
|
|
||||||
SELECT id FROM catalog_pages
|
|
||||||
`)) as unknown as [Array<{ id: number }>, unknown];
|
|
||||||
const validPageIds = new Set(validPages.map((r) => Number(r.id)));
|
|
||||||
|
|
||||||
// Only items already living under the Imported Furniture section are
|
|
||||||
// reorganized. An item counts as "in import" when its catalog entry sits on
|
|
||||||
// a sub-page (or the parent) of the imported parent page.
|
|
||||||
const [importedPages] = (await db.execute(sql`
|
|
||||||
SELECT id FROM catalog_pages
|
|
||||||
WHERE parent_id = ${parentPageId} OR id = ${parentPageId}
|
|
||||||
`)) as unknown as [Array<{ id: number }>, unknown];
|
|
||||||
const importedPageIds = new Set(importedPages.map((r) => Number(r.id)));
|
|
||||||
const importedItemIds = new Set<number>();
|
|
||||||
for (const row of catalogRows) {
|
|
||||||
if (!importedPageIds.has(Number(row.page_id))) continue;
|
|
||||||
for (const id of (row.item_ids ?? "")
|
|
||||||
.split(";")
|
|
||||||
.map((s) => Number(s.trim()))
|
|
||||||
.filter(Boolean)) {
|
|
||||||
importedItemIds.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const itemById = new Map(items.map((it) => [Number(it.id), it]));
|
|
||||||
|
|
||||||
// Count imported items per furniture line so we only spin up a dedicated
|
|
||||||
// line page for lines that actually have enough items. Everything else
|
|
||||||
// falls back to the category page.
|
|
||||||
const lineCounts = new Map<string, number>();
|
|
||||||
for (const id of importedItemIds) {
|
|
||||||
const item = itemById.get(id);
|
|
||||||
if (!item) continue;
|
|
||||||
if (NO_CATALOG_KINDS.has(classifyCatalogItem(item))) continue;
|
|
||||||
const fl = resolveFurniline(item.item_name);
|
|
||||||
if (fl) lineCounts.set(fl, (lineCounts.get(fl) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
const qualifyingLines = new Set<string>();
|
|
||||||
for (const [line, count] of lineCounts) {
|
|
||||||
if (count >= LINE_PAGE_MIN_ITEMS) qualifyingLines.add(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalogAdded = 0;
|
|
||||||
let catalogRehomed = 0;
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const kind = classifyCatalogItem(item);
|
|
||||||
if (NO_CATALOG_KINDS.has(kind)) continue;
|
|
||||||
|
|
||||||
const itemId = Number(item.id);
|
|
||||||
|
|
||||||
// Only reorganize items already in the Imported Furniture section.
|
|
||||||
// Never pull new items in from items_base.
|
|
||||||
if (!importedItemIds.has(itemId)) continue;
|
|
||||||
|
|
||||||
// Target page: the item's furniture line (e.g. "weebz", "habbox") when
|
|
||||||
// it qualifies for its own page, otherwise its auto-detected category.
|
|
||||||
// The line page is created if it does not exist yet.
|
|
||||||
let captionSave: string;
|
|
||||||
let caption: string;
|
|
||||||
const furniline = resolveFurniline(item.item_name);
|
|
||||||
if (furniline && slug(furniline) && qualifyingLines.has(furniline)) {
|
|
||||||
captionSave = `imp_line_${slug(furniline)}`;
|
|
||||||
caption = furniline;
|
|
||||||
} else {
|
|
||||||
const catKey = classifyFurni(item.item_name, item.type);
|
|
||||||
captionSave = `imp_${catKey}`;
|
|
||||||
caption = CATEGORY_PAGE[catKey]?.label ?? CATEGORY_PAGE.other.label;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!existingCaptions.has(captionSave) &&
|
|
||||||
!createdCaptions.has(captionSave)
|
|
||||||
) {
|
|
||||||
createdCaptions.add(captionSave);
|
|
||||||
pagesCreated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageId = await ensureSubPage(captionSave, caption);
|
|
||||||
const price = autoPriceFurni(item.item_name);
|
|
||||||
|
|
||||||
// Re-classify onto the correct sub-page and re-apply the auto price if
|
|
||||||
// either drifted away from the canonical values.
|
|
||||||
const currentPage = catalogPageByItem.get(itemId);
|
|
||||||
const currentCredits = catalogCreditsByItem.get(itemId);
|
|
||||||
const needsMove =
|
|
||||||
currentPage === undefined ||
|
|
||||||
!validPageIds.has(currentPage) ||
|
|
||||||
currentPage !== pageId;
|
|
||||||
const needsReprice = currentCredits !== price.credits;
|
|
||||||
if (needsMove || needsReprice) {
|
|
||||||
await db.execute(sql`
|
|
||||||
UPDATE catalog_items
|
|
||||||
SET page_id = ${pageId},
|
|
||||||
cost_credits = ${price.credits},
|
|
||||||
cost_points = ${price.points},
|
|
||||||
points_type = ${price.pointsType}
|
|
||||||
WHERE item_ids = ${String(itemId)}
|
|
||||||
`);
|
|
||||||
catalogRehomed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetCatalogPageCache();
|
|
||||||
|
|
||||||
return { pagesCreated, catalogAdded, catalogRehomed };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrphanedCatalogResult {
|
export interface OrphanedCatalogResult {
|
||||||
removed: number;
|
removed: number;
|
||||||
cleaned: number;
|
cleaned: number;
|
||||||
|
|||||||
@@ -324,22 +324,50 @@ export async function buildLocalizedFurniDataFiles(): Promise<
|
|||||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||||
const arr = cloned[section]?.furnitype;
|
const arr = cloned[section]?.furnitype;
|
||||||
if (!Array.isArray(arr)) continue;
|
if (!Array.isArray(arr)) continue;
|
||||||
|
// A language mapped to hotel "com" only carries English names,
|
||||||
|
// so it must NOT be treated as a real translation — fall through
|
||||||
|
// to LibreTranslate for those (pl/ru/ar/ja).
|
||||||
|
const officialIsReal = lang === "en" || hotel !== "com";
|
||||||
for (const entry of arr) {
|
for (const entry of arr) {
|
||||||
const cn =
|
const cn =
|
||||||
typeof entry.classname === "string" ? entry.classname : "";
|
typeof entry.classname === "string" ? entry.classname : "";
|
||||||
if (!cn) continue;
|
if (!cn) continue;
|
||||||
const t = lookupTranslation(map, cn);
|
const t = lookupTranslation(map, cn);
|
||||||
if (!t) continue;
|
|
||||||
// Only override when the translation carries content — empty
|
|
||||||
// strings in official data would otherwise blank existing names.
|
|
||||||
let didTranslate = false;
|
let didTranslate = false;
|
||||||
if (t.name) {
|
// Prefer the official Habbo translation when it carries content;
|
||||||
|
// otherwise fall back to LibreTranslate (customs, or languages
|
||||||
|
// whose hotel only provides English) so every entry gets a
|
||||||
|
// localized name/description rather than staying English.
|
||||||
|
if (officialIsReal && t?.name) {
|
||||||
entry.name = t.name;
|
entry.name = t.name;
|
||||||
didTranslate = true;
|
didTranslate = true;
|
||||||
|
} else if (
|
||||||
|
lang !== "en" &&
|
||||||
|
typeof entry.name === "string" &&
|
||||||
|
entry.name.trim()
|
||||||
|
) {
|
||||||
|
const translated = await translateWithLibre(entry.name, lang);
|
||||||
|
if (translated && translated !== entry.name) {
|
||||||
|
entry.name = translated;
|
||||||
|
didTranslate = true;
|
||||||
}
|
}
|
||||||
if (t.description) {
|
}
|
||||||
|
if (officialIsReal && t?.description) {
|
||||||
entry.description = t.description;
|
entry.description = t.description;
|
||||||
didTranslate = true;
|
didTranslate = true;
|
||||||
|
} else if (
|
||||||
|
lang !== "en" &&
|
||||||
|
typeof entry.description === "string" &&
|
||||||
|
entry.description.trim()
|
||||||
|
) {
|
||||||
|
const translated = await translateWithLibre(
|
||||||
|
entry.description,
|
||||||
|
lang,
|
||||||
|
);
|
||||||
|
if (translated && translated !== entry.description) {
|
||||||
|
entry.description = translated;
|
||||||
|
didTranslate = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (didTranslate) {
|
if (didTranslate) {
|
||||||
if (section === "roomitemtypes") translatedRoom++;
|
if (section === "roomitemtypes") translatedRoom++;
|
||||||
@@ -361,6 +389,10 @@ export async function buildLocalizedFurniDataFiles(): Promise<
|
|||||||
await fs.rename(tmp, target);
|
await fs.rename(tmp, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist the LibreTranslate cache once per language so the next
|
||||||
|
// full build reuses translations instead of re-calling the API.
|
||||||
|
if (lang !== "en") await saveLibreCache();
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
lang,
|
lang,
|
||||||
hotel,
|
hotel,
|
||||||
|
|||||||
@@ -153,17 +153,101 @@ export class RconClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
|
/**
|
||||||
|
* HTTP transport for emulators that expose an API instead of RCON's raw-JSON
|
||||||
|
* TCP protocol. It POSTs the exact same `{"key":...,"data":...}` payload the
|
||||||
|
* TCP transport writes, so the RconClient command shapes stay identical.
|
||||||
|
*
|
||||||
|
* Mirrors the TCP transport's best-effort semantics: a 2xx response is treated
|
||||||
|
* as delivered (`true`); any non-2xx, network error, or timeout is logged and
|
||||||
|
* treated as not delivered (`false`).
|
||||||
|
*/
|
||||||
|
export function httpTransport(opts: {
|
||||||
|
url: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
apiKey?: string;
|
||||||
|
apiKeyHeader?: string;
|
||||||
|
maxRetries: number;
|
||||||
|
}): RconTransport {
|
||||||
|
const sendOnce = (payload: RconPayload): Promise<boolean> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "application/json",
|
||||||
|
};
|
||||||
|
if (opts.apiKey) {
|
||||||
|
const header = opts.apiKeyHeader ?? "Authorization";
|
||||||
|
// Accept either a raw token or a full scheme (e.g. "Bearer xyz").
|
||||||
|
headers[header] = /\s/.test(opts.apiKey)
|
||||||
|
? opts.apiKey
|
||||||
|
: `Bearer ${opts.apiKey}`;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
||||||
|
fetch(opts.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(res.ok);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export const rcon =
|
return async (payload) => {
|
||||||
globalForRcon.rcon ??
|
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
|
||||||
new RconClient(
|
if (await sendOnce(payload)) return true;
|
||||||
tcpTransport({
|
if (attempt < opts.maxRetries - 1) {
|
||||||
|
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.error("Emulator API delivery failed after retries", {
|
||||||
|
module: "emulator-api",
|
||||||
|
key: payload.key,
|
||||||
|
url: opts.url,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configured emulator transport based on `EMULATOR_MODE`:
|
||||||
|
* - "rcon" (default): raw-JSON TCP transport.
|
||||||
|
* - "api": HTTP transport posting the RCON payload to `EMULATOR_API_URL`.
|
||||||
|
*
|
||||||
|
* Throws at construction time when api mode is selected without a URL so the
|
||||||
|
* misconfiguration surfaces immediately instead of on first command.
|
||||||
|
*/
|
||||||
|
export function createEmulatorTransport(): RconTransport {
|
||||||
|
if (env.EMULATOR_MODE === "api") {
|
||||||
|
if (!env.EMULATOR_API_URL) {
|
||||||
|
throw new Error("EMULATOR_MODE=api requires EMULATOR_API_URL to be set");
|
||||||
|
}
|
||||||
|
return httpTransport({
|
||||||
|
url: env.EMULATOR_API_URL,
|
||||||
|
timeoutMs: env.RCON_TIMEOUT_MS,
|
||||||
|
apiKey: env.EMULATOR_API_KEY,
|
||||||
|
apiKeyHeader: env.EMULATOR_API_KEY_HEADER,
|
||||||
|
maxRetries: env.RCON_MAX_RETRIES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tcpTransport({
|
||||||
host: env.RCON_HOST,
|
host: env.RCON_HOST,
|
||||||
port: env.RCON_PORT,
|
port: env.RCON_PORT,
|
||||||
timeoutMs: env.RCON_TIMEOUT_MS,
|
timeoutMs: env.RCON_TIMEOUT_MS,
|
||||||
maxRetries: env.RCON_MAX_RETRIES,
|
maxRetries: env.RCON_MAX_RETRIES,
|
||||||
}),
|
});
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
|
||||||
|
|
||||||
|
export const rcon =
|
||||||
|
globalForRcon.rcon ?? new RconClient(createEmulatorTransport());
|
||||||
|
|
||||||
if (env.NODE_ENV !== "production") globalForRcon.rcon = rcon;
|
if (env.NODE_ENV !== "production") globalForRcon.rcon = rcon;
|
||||||
Reference in new issue
Block a user