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
@@ -11,12 +11,7 @@ import {
|
||||
classifyFurni,
|
||||
getOrCreateCategoryPage,
|
||||
getOrCreateImportedParentPage,
|
||||
resetCatalogPageCache,
|
||||
} from "@/lib/services/furni-import";
|
||||
import {
|
||||
classifyCatalogItem,
|
||||
NO_CATALOG_KINDS,
|
||||
} from "@/lib/services/item-kind";
|
||||
|
||||
export interface MissingCatalogEntry {
|
||||
classname: string;
|
||||
@@ -452,246 +447,6 @@ export async function applyCatalogSql(
|
||||
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 {
|
||||
removed: number;
|
||||
cleaned: number;
|
||||
|
||||
@@ -324,22 +324,50 @@ export async function buildLocalizedFurniDataFiles(): Promise<
|
||||
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
|
||||
const arr = cloned[section]?.furnitype;
|
||||
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) {
|
||||
const cn =
|
||||
typeof entry.classname === "string" ? entry.classname : "";
|
||||
if (!cn) continue;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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 (section === "roomitemtypes") translatedRoom++;
|
||||
@@ -361,6 +389,10 @@ export async function buildLocalizedFurniDataFiles(): Promise<
|
||||
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({
|
||||
lang,
|
||||
hotel,
|
||||
|
||||
@@ -153,17 +153,101 @@ export class 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);
|
||||
});
|
||||
});
|
||||
|
||||
return async (payload) => {
|
||||
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
|
||||
if (await sendOnce(payload)) return true;
|
||||
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,
|
||||
port: env.RCON_PORT,
|
||||
timeoutMs: env.RCON_TIMEOUT_MS,
|
||||
maxRetries: env.RCON_MAX_RETRIES,
|
||||
});
|
||||
}
|
||||
|
||||
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
|
||||
|
||||
export const rcon =
|
||||
globalForRcon.rcon ??
|
||||
new RconClient(
|
||||
tcpTransport({
|
||||
host: env.RCON_HOST,
|
||||
port: env.RCON_PORT,
|
||||
timeoutMs: env.RCON_TIMEOUT_MS,
|
||||
maxRetries: env.RCON_MAX_RETRIES,
|
||||
}),
|
||||
);
|
||||
globalForRcon.rcon ?? new RconClient(createEmulatorTransport());
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForRcon.rcon = rcon;
|
||||
Reference in new issue
Block a user