perf: offload FurnitureData translation build to a worker thread
CI / check (push) Successful in 33s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m0s

The translation build's CPU-heavy work (deep clone of the 100k+ entry
master, the two full scans and JSON.stringify per language) now runs in a
worker thread, so "Alles vertalen" no longer blocks the main event loop.
Network/DB parts (official Habbo fetch, LibreTranslate, file writes) stay
on the main thread. The pure helpers were extracted to furni-data-i18n-core
(no server-only deps) so the worker can import them. Falls back to the same
helpers on the main thread when no worker is available or in tests.
This commit is contained in:
openhands committed 2026-08-24 19:10:33 +02:00
1 parent 8d1b09f151
commit f94246e067
4 files changed
+451 -124

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
/**
* Pure, side-effect-free helpers for building localized FurnitureData files.
*
* This module deliberately imports NOTHING from site-settings / server-only /
* network so it can run safely inside a worker thread. All I/O (fetching
* official Habbo translations, calling LibreTranslate, writing files) stays on
* the main thread; this module only does the CPU-heavy clone + scan + stringify.
*/
import type { HabboGamedataHotel } from "@/lib/habbo-gamedata-hotel";
export type Translation = { name: string; description: string };
export type FurniDataEntry = Record<string, unknown>;
export interface FurniDataSection {
furnitype: FurniDataEntry[];
}
export type FurniDataMaster = Record<string, FurniDataSection>;
export function cloneFurniData(data: Record<string, unknown>): FurniDataMaster {
// Deep clone via JSON — structure is pure JSON and 100k+ entries; this is the
// simplest safe copy so each language build gets its own mutable copy.
return JSON.parse(JSON.stringify(data)) as FurniDataMaster;
}
export function officialIsRealTranslation(
lang: string,
hotel: HabboGamedataHotel,
): boolean {
// 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.
return lang === "en" || hotel !== "com";
}
export function lookupTranslation(
map: Map<string, Translation>,
classname: string,
): Translation | undefined {
const exact = map.get(classname);
if (exact) return exact;
if (classname.includes("*")) {
const base = classname.split("*")[0];
const v0 = map.get(`${base}*0`);
if (v0) return v0;
const baseExact = map.get(base);
if (baseExact) return baseExact;
}
if (!classname.includes("*")) {
const v0 = map.get(`${classname}*0`);
if (v0) return v0;
}
return undefined;
}
export interface PendingRef {
entry: FurniDataEntry;
field: "name" | "description";
section: "roomitemtypes" | "wallitemtypes";
}
export interface PreparedLang {
cloned: FurniDataMaster;
pendingRefs: PendingRef[];
pendingTexts: string[];
}
/**
* Pass 1: deep-clone the master, apply official translations directly, and
* collect the texts that still need LibreTranslate. Returns the cloned object
* plus the pending refs/texts so the (network) translation step can run and the
* result be applied later without re-scanning.
*/
export function prepareLangFile(
master: FurniDataMaster,
lang: string,
hotel: HabboGamedataHotel,
officialMap: Map<string, Translation>,
): PreparedLang {
const cloned = cloneFurniData(master);
const officialIsReal = officialIsRealTranslation(lang, hotel);
const pendingRefs: PendingRef[] = [];
const pendingTexts = new Set<string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const entry of cloned[section]?.furnitype ?? []) {
const cn = typeof entry.classname === "string" ? entry.classname : "";
if (!cn) continue;
const t = lookupTranslation(officialMap, cn);
if (officialIsReal && t?.name) {
entry.name = t.name;
} else if (
lang !== "en" &&
typeof entry.name === "string" &&
entry.name.trim()
) {
pendingTexts.add(entry.name.trim());
pendingRefs.push({ entry, field: "name", section });
}
if (officialIsReal && t?.description) {
entry.description = t.description;
} else if (
lang !== "en" &&
typeof entry.description === "string" &&
entry.description.trim()
) {
pendingTexts.add(entry.description.trim());
pendingRefs.push({ entry, field: "description", section });
}
}
}
return { cloned, pendingRefs, pendingTexts: [...pendingTexts] };
}
export interface FinalizedLang {
json: string;
translatedRoom: number;
translatedWall: number;
total: number;
}
/**
* Pass 2: apply the LibreTranslate results to the prepared clone and serialize
* it. Kept separate from prepareLangFile so the (async, network) translation
* step can run in between on the main thread.
*/
export function finalizeLangFile(
cloned: FurniDataMaster,
pendingRefs: PendingRef[],
translations: Map<string, string>,
): FinalizedLang {
const counted = new Set<FurniDataEntry>();
let translatedRoom = 0;
let translatedWall = 0;
for (const ref of pendingRefs) {
const original = ref.entry[ref.field];
if (typeof original !== "string") continue;
const translated = translations.get(original.trim());
if (translated === undefined || translated === original) continue;
ref.entry[ref.field] = translated;
if (!counted.has(ref.entry)) {
counted.add(ref.entry);
if (ref.section === "roomitemtypes") translatedRoom++;
else translatedWall++;
}
}
const total =
(cloned.roomitemtypes?.furnitype?.length ?? 0) +
(cloned.wallitemtypes?.furnitype?.length ?? 0);
return {
json: JSON.stringify(cloned),
translatedRoom,
translatedWall,
total,
};
}