perf: offload per-import localized patch to worker + cache FurnitureData parse

Per single furni import, patchLocalizedFurniDataEntries ran for every
language and did a full 100k-entry JSON.parse + scan + JSON.stringify on the
main thread. That CPU spike is now offloaded to the translation worker
(init/prepare/finalize reuses the same pure core helpers), so importing
never blocks the event loop. The main thread only does async file I/O and the
network LibreTranslate calls. Falls back to the same helpers on the main
thread if no worker is available.

Also cache readFurniData() by file mtime+size so the 100k-entry JSON is
parsed once per change instead of on every import/fix/reconcile read
(invalidated on write).
This commit is contained in:
openhands committed 2026-08-24 19:29:21 +02:00
1 parent 6ed1b03e24
commit 6ae7c09682
5 files changed
+502 -226

No files matched your search

+239
View File
@@ -153,3 +153,242 @@ export function finalizeLangFile(
total,
};
}
/* ------------------------------------------------------------------ */
/* Localized patch (single-item import path) */
/* ------------------------------------------------------------------ */
export interface PatchEntry {
classname: string;
name?: string;
description?: string;
}
interface PatchRef {
entry: FurniDataEntry;
field: "name" | "description";
}
interface PatchNewItem {
section: "roomitemtypes" | "wallitemtypes";
found: FurniDataEntry;
name?: string;
description?: string;
translateName: boolean;
translateDesc: boolean;
}
export interface PreparedPatch {
data: FurniDataMaster;
refs: PatchRef[];
newItems: PatchNewItem[];
pendingTexts: Set<string>;
officialPatched: number;
}
function findEntryInFurniData(
data: FurniDataMaster,
classname: string,
): {
entry: FurniDataEntry;
section: "roomitemtypes" | "wallitemtypes";
} | null {
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
const hit = data[section]?.furnitype.find((e) => e.classname === classname);
if (hit) return { entry: hit, section };
}
return null;
}
/**
* Pass 1 of a localized patch: load the existing language file (or clone the
* master when it does not exist yet), touch ONLY the wanted classnames, and
* collect the texts that still need LibreTranslate. The official-vs-Libre
* decision, master clone and the full file scan all run here (CPU-heavy) so the
* network translation step can run afterwards on the main thread.
*/
export function preparePatch(
fileContent: string | null,
master: FurniDataMaster,
lang: string,
hotel: HabboGamedataHotel,
officialMap: Map<string, Translation>,
entries: PatchEntry[],
): PreparedPatch {
const officialIsReal = officialIsRealTranslation(lang, hotel);
let data: FurniDataMaster;
try {
data = fileContent
? (JSON.parse(fileContent) as FurniDataMaster)
: cloneFurniData(master);
if (!fileContent && officialIsReal) {
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const entry of data[section]?.furnitype ?? []) {
const cn = typeof entry.classname === "string" ? entry.classname : "";
if (!cn) continue;
const t = lookupTranslation(officialMap, cn);
if (t?.name) entry.name = t.name;
if (t?.description) entry.description = t.description;
}
}
}
} catch {
data = cloneFurniData(master);
}
const wanted = new Map(entries.map((e) => [e.classname, e]));
const wantedSet = new Set(wanted.keys());
const refs: PatchRef[] = [];
const pendingTexts = new Set<string>();
let officialPatched = 0;
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const entry of data[section]?.furnitype ?? []) {
const cn = typeof entry.classname === "string" ? entry.classname : "";
if (!cn || !wantedSet.has(cn)) continue;
const wantedEntry = wanted.get(cn);
if (!wantedEntry) continue;
const t = lookupTranslation(officialMap, cn);
const origName =
wantedEntry.name ?? (typeof entry.name === "string" ? entry.name : "");
const origDesc =
wantedEntry.description ??
(typeof entry.description === "string" ? entry.description : "");
let didPatch = false;
if (officialIsReal && t?.name) {
entry.name = t.name;
didPatch = true;
} else if (lang !== "en" && origName) {
pendingTexts.add(origName);
refs.push({ entry, field: "name" });
}
if (officialIsReal && t?.description) {
entry.description = t.description;
didPatch = true;
} else if (lang !== "en" && origDesc) {
pendingTexts.add(origDesc);
refs.push({ entry, field: "description" });
}
if (didPatch) officialPatched++;
}
}
// New classnames not yet in the file: copy from master with translation applied.
const existingClassnames = new Set<string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of data[section]?.furnitype ?? []) {
const cn = typeof e?.classname === "string" ? e.classname : "";
if (cn) existingClassnames.add(cn);
}
}
const newItems: PatchNewItem[] = [];
const newTexts = new Set<string>();
for (const { classname, name, description } of entries) {
if (existingClassnames.has(classname)) continue;
const found = findEntryInFurniData(master, classname);
if (!found) continue;
const t = lookupTranslation(officialMap, classname);
const srcName =
name ?? (typeof found.entry.name === "string" ? found.entry.name : "");
const srcDesc =
description ??
(typeof found.entry.description === "string"
? found.entry.description
: "");
let finalName: string | undefined;
let finalDesc: string | undefined;
let translateName = false;
let translateDesc = false;
if (officialIsReal && t?.name) finalName = t.name;
else if (lang !== "en" && srcName) {
finalName = srcName;
translateName = true;
newTexts.add(srcName);
} else finalName = srcName;
if (officialIsReal && t?.description) finalDesc = t.description;
else if (lang !== "en" && srcDesc) {
finalDesc = srcDesc;
translateDesc = true;
newTexts.add(srcDesc);
} else finalDesc = srcDesc;
newItems.push({
section: found.section,
found: found.entry,
name: finalName,
description: finalDesc,
translateName,
translateDesc,
});
}
for (const tx of newTexts) pendingTexts.add(tx);
return { data, refs, newItems, pendingTexts, officialPatched };
}
export interface FinalizedPatch {
json: string;
patched: number;
added: number;
total: number;
}
/**
* Pass 2 of a localized patch: apply the (network) LibreTranslate results to the
* prepared clone and serialize it. New items are appended from the master copy.
*/
export function finalizePatch(
prepared: PreparedPatch,
translations: Map<string, string>,
): FinalizedPatch {
const { data, refs, newItems, officialPatched } = prepared;
const counted = new Set<FurniDataEntry>();
let librePatched = 0;
for (const ref of refs) {
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);
librePatched++;
}
}
let added = 0;
for (const item of newItems) {
const clone = { ...item.found } as FurniDataEntry;
if (item.name !== undefined) {
if (item.translateName) {
const tr = translations.get(item.name.trim());
clone.name = tr && tr !== item.name ? tr : item.name;
} else {
clone.name = item.name;
}
}
if (item.description !== undefined) {
if (item.translateDesc) {
const tr = translations.get(item.description.trim());
clone.description =
tr && tr !== item.description ? tr : item.description;
} else {
clone.description = item.description;
}
}
if (!data[item.section]) data[item.section] = { furnitype: [] };
data[item.section].furnitype.push(clone);
added++;
}
const total =
(data.roomitemtypes?.furnitype?.length ?? 0) +
(data.wallitemtypes?.furnitype?.length ?? 0);
return {
json: JSON.stringify(data),
patched: officialPatched + librePatched + added,
added,
total,
};
}
+81 -205
View File
@@ -69,8 +69,6 @@ export interface LanguageFileResult {
error?: string;
}
import type { Translation } from "./furni-data-i18n-core";
const LANG_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours — translations rarely change
interface LangCacheEntry {
@@ -257,7 +255,12 @@ async function translateManyWithLibre(
return result;
}
import type { FurniDataMaster, PreparedLang } from "./furni-data-i18n-core";
import type {
FurniDataMaster,
PreparedLang,
PreparedPatch,
Translation,
} from "./furni-data-i18n-core";
/**
* The pure clone / scan / stringify helpers live in furni-data-i18n-core so they
* can run inside a worker thread (that module has no server-only / network deps).
@@ -265,25 +268,26 @@ import type { FurniDataMaster, PreparedLang } from "./furni-data-i18n-core";
* backward compatibility.
*/
import {
cloneFurniData,
finalizeLangFile,
finalizePatch,
lookupTranslation,
officialIsRealTranslation,
prepareLangFile,
preparePatch,
} from "./furni-data-i18n-core";
export type {
FurniDataMaster,
PatchEntry,
PreparedLang,
PreparedPatch,
Translation,
} from "./furni-data-i18n-core";
export {
cloneFurniData,
lookupTranslation,
officialIsRealTranslation,
} from "./furni-data-i18n-core";
async function fetchTranslationsForHotel(
export async function fetchTranslationsForHotel(
hotel: HabboGamedataHotel,
): Promise<Map<string, Translation>> {
const cached = langCache.get(hotel);
@@ -400,16 +404,12 @@ export async function buildLocalizedFurniDataFiles(
{ furnitype: Array<Record<string, unknown>> }
>;
// Fetch each hotel's official translations once (network + per-hotel cache).
const hotels = [...new Set(langsToBuild.map((l) => l.hotel))];
const hotelMaps: Record<string, Map<string, Translation>> = {};
for (const hotel of hotels) {
hotelMaps[hotel] = await fetchTranslationsForHotel(hotel);
}
const worker = getTranslationWorker();
if (worker) {
await worker.init(JSON.stringify(master), hotelMaps);
// Reads the master + official maps once on the main thread (cached
// per process) and ships them to the worker so the per-language
// clone/scan/stringify runs off the main thread.
await worker.ensureInit();
}
// Main-thread fallback store, used only when no worker is available.
const localPrepared = new Map<string, PreparedLang>();
@@ -430,11 +430,12 @@ export async function buildLocalizedFurniDataFiles(
if (worker) {
pendingTexts = await worker.prepare(lang, hotel);
} else {
const map = await fetchTranslationsForHotel(hotel);
const r = prepareLangFile(
master as unknown as FurniDataMaster,
lang,
hotel,
hotelMaps[hotel] ?? new Map(),
map,
);
pendingTexts = r.pendingTexts;
preparedKey = lang;
@@ -531,220 +532,95 @@ export async function patchLocalizedFurniDataEntries(
: FURNIDATA_LANGUAGES;
return withFurniDataLock(async () => {
// Read the master once and reuse it for the fallback clone and for
// looking up brand-new classnames — avoids parsing FurnitureData twice.
const master = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>;
const maps = await Promise.all(
langsToBuild.map(async ({ lang, hotel }) => {
const map = await fetchTranslationsForHotel(hotel);
return { lang, hotel, map };
}),
);
const wanted = new Map<string, { name?: string; description?: string }>();
for (const e of entries) wanted.set(e.classname, e);
const wantedSet = new Set(wanted.keys());
const worker = getTranslationWorker();
// Fallback store for when there is no worker (test env / spawn failure).
const localPrepared = new Map<string, PreparedPatch>();
const results: LanguageFileResult[] = [];
for (const { lang, hotel, map } of maps) {
for (const { lang, hotel } of langsToBuild) {
try {
const officialIsReal = officialIsRealTranslation(lang, hotel);
const targets = await getLocalizedWritePaths(lang);
let patched = 0;
// Load the existing language file if present, otherwise build it
// from master (official translations only — never LibreTranslate,
// to avoid 100k+ network calls on first creation).
let data: Record<string, { furnitype: Array<Record<string, unknown>> }>;
// Read the existing language file as raw text. The expensive
// parse + scan + stringify all happen in the worker, so the main
// thread only does async file I/O here.
let fileContent: string | null = null;
try {
const raw = await fs.readFile(targets[0], "utf-8");
data = JSON.parse(raw);
fileContent = await fs.readFile(targets[0], "utf-8");
} catch {
data = cloneFurniData(master);
if (officialIsReal) {
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const entry of data[section]?.furnitype ?? []) {
const cn =
typeof entry.classname === "string" ? entry.classname : "";
if (!cn) continue;
const t = lookupTranslation(map, cn);
if (t?.name) entry.name = t.name;
if (t?.description) entry.description = t.description;
}
}
}
fileContent = null;
}
// Pass 1 — touch ONLY the wanted classnames (most patches are a
// single import), collecting the texts that need LibreTranslate.
const refs: Array<{
entry: Record<string, unknown>;
field: "name" | "description";
}> = [];
const pendingTexts = new Set<string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const entry of data[section]?.furnitype ?? []) {
const cn =
typeof entry.classname === "string" ? entry.classname : "";
if (!cn || !wantedSet.has(cn)) continue;
const wantedEntry = wanted.get(cn);
if (!wantedEntry) continue;
const t = lookupTranslation(map, cn);
let didPatch = false;
const origName =
wantedEntry.name ??
(typeof entry.name === "string" ? entry.name : "");
const origDesc =
wantedEntry.description ??
(typeof entry.description === "string" ? entry.description : "");
if (officialIsReal && t?.name) {
entry.name = t.name;
didPatch = true;
} else if (lang !== "en" && origName) {
pendingTexts.add(origName);
refs.push({ entry, field: "name" });
}
if (officialIsReal && t?.description) {
entry.description = t.description;
didPatch = true;
} else if (lang !== "en" && origDesc) {
pendingTexts.add(origDesc);
refs.push({ entry, field: "description" });
}
if (didPatch) patched++;
}
let pendingTexts: string[];
let localKey: string | null = null;
try {
if (!worker) throw new Error("no translation worker");
pendingTexts = await worker.patchPrepare(
fileContent,
lang,
hotel,
entries,
);
} catch {
// Main-thread fallback: run the exact same pure helpers here.
const master = (await readFurniData()) as unknown as FurniDataMaster;
const map = await fetchTranslationsForHotel(hotel);
const r = preparePatch(
fileContent,
master,
lang,
hotel,
map,
entries,
);
localPrepared.set(lang, r);
pendingTexts = [...r.pendingTexts];
localKey = lang;
}
// Batch-translate (dedup + concurrency-capped) instead of one
// HTTP call per text.
const translations = await translateManyWithLibre(pendingTexts, lang);
const counted = new Set<Record<string, unknown>>();
for (const ref of refs) {
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);
patched++;
}
// Batch-translate (network, async — never blocks the main thread).
const translations = await translateManyWithLibre(
new Set(pendingTexts),
lang,
);
let json: string;
let patched = 0;
let added = 0;
let total = 0;
if (worker && localKey === null) {
const res = await worker.patchFinalize(lang, translations);
json = res.json;
patched = res.patched;
added = res.added;
total = res.total;
} else {
const r = localPrepared.get(localKey!)!;
const res = finalizePatch(r, translations);
json = res.json;
patched = res.patched;
added = res.added;
total = res.total;
localPrepared.delete(localKey!);
}
// New classnames not yet in the file: copy from master with
// translation applied (only for the wanted set).
const existingClassnames = new Set<string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of data[section]?.furnitype ?? []) {
const cn = typeof e?.classname === "string" ? e.classname : "";
if (cn) existingClassnames.add(cn);
}
}
const newItems: Array<{
section: "roomitemtypes" | "wallitemtypes";
found: Record<string, unknown>;
name?: string;
description?: string;
translateName: boolean;
translateDesc: boolean;
}> = [];
const newTexts = new Set<string>();
for (const { classname, name, description } of entries) {
if (existingClassnames.has(classname)) continue;
let found: Record<string, unknown> | undefined;
let foundSection: "roomitemtypes" | "wallitemtypes" | undefined;
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
const hit = (
master[section]?.furnitype as
| Array<Record<string, unknown>>
| undefined
)?.find((e) => e.classname === classname) as
| Record<string, unknown>
| undefined;
if (hit) {
found = hit;
foundSection = section;
break;
}
}
if (!found || !foundSection) continue;
const t = lookupTranslation(map, classname);
const srcName =
name ?? (typeof found.name === "string" ? found.name : "");
const srcDesc =
description ??
(typeof found.description === "string" ? found.description : "");
let finalName: string | undefined;
let finalDesc: string | undefined;
let translateName = false;
let translateDesc = false;
if (officialIsReal && t?.name) finalName = t.name;
else if (lang !== "en" && srcName) {
finalName = srcName;
translateName = true;
newTexts.add(srcName);
} else finalName = srcName;
if (officialIsReal && t?.description) finalDesc = t.description;
else if (lang !== "en" && srcDesc) {
finalDesc = srcDesc;
translateDesc = true;
newTexts.add(srcDesc);
} else finalDesc = srcDesc;
newItems.push({
section: foundSection,
found,
name: finalName,
description: finalDesc,
translateName,
translateDesc,
});
}
const newTranslations = await translateManyWithLibre(newTexts, lang);
for (const item of newItems) {
const clone = { ...item.found } as Record<string, unknown>;
if (item.name !== undefined) {
if (item.translateName) {
const tr = newTranslations.get(item.name.trim());
clone.name = tr && tr !== item.name ? tr : item.name;
} else {
clone.name = item.name;
}
}
if (item.description !== undefined) {
if (item.translateDesc) {
const tr = newTranslations.get(item.description.trim());
clone.description =
tr && tr !== item.description ? tr : item.description;
} else {
clone.description = item.description;
}
}
if (!data[item.section]) data[item.section] = { furnitype: [] };
data[item.section].furnitype.push(clone);
patched++;
}
if (lang !== "en") await saveLibreCache();
const payload = JSON.stringify(data);
for (const target of targets) {
await fs.mkdir(path.dirname(target), { recursive: true });
const tmp = `${target}.tmp`;
await fs.writeFile(tmp, payload, "utf-8");
await fs.writeFile(tmp, json, "utf-8");
await fs.rename(tmp, target);
}
if (lang !== "en") await saveLibreCache();
results.push({
lang,
hotel,
file: targets[0] ?? `FurnitureData_${lang}.json`,
translatedRoom: patched,
translatedWall: 0,
total: patched,
translatedWall: added,
total,
ok: true,
});
} catch (err) {
+28 -1
View File
@@ -199,10 +199,34 @@ export async function withFurniDataLock<T>(fn: () => Promise<T>): Promise<T> {
}
}
let cachedFurniData: {
path: string;
mtimeMs: number;
size: number;
data: Record<string, unknown>;
} | null = null;
export async function readFurniData(): Promise<Record<string, unknown>> {
const target = await getFurnitureDataReadPath();
const stat = await fs.stat(target);
if (
cachedFurniData &&
cachedFurniData.path === target &&
cachedFurniData.mtimeMs === stat.mtimeMs &&
cachedFurniData.size === stat.size
) {
// File unchanged since last parse: skip the (expensive) full JSON.parse.
return cachedFurniData.data;
}
const raw = await fs.readFile(/*turbopackIgnore: true*/ target, "utf-8");
return JSON.parse(raw);
const data = JSON.parse(raw) as Record<string, unknown>;
cachedFurniData = {
path: target,
mtimeMs: stat.mtimeMs,
size: stat.size,
data,
};
return data;
}
export async function writeFurniData(
@@ -216,6 +240,9 @@ export async function writeFurniData(
await fs.writeFile(/*turbopackIgnore: true*/ tmp, payload, "utf-8");
await fs.rename(/*turbopackIgnore: true*/ tmp, target);
}
// The file just changed: force the next read to reparse instead of serving
// a possibly-mutated cached object.
cachedFurniData = null;
}
export function buildFurniEntry(params: {
+95 -14
View File
@@ -1,12 +1,12 @@
import { Worker } from "node:worker_threads";
import type { Translation } from "./furni-data-i18n-core";
import type { PatchEntry, Translation } from "./furni-data-i18n-core";
/**
* Runs the CPU-heavy FurnitureData localization (deep clone + 100k-entry scans +
* JSON.stringify) in a single worker thread so the main Node event loop stays
* free during a "translate everything" build. Network/DB work (fetching official
* Habbo translations, calling LibreTranslate, writing files) stays on the main
* thread; only the synchronous CPU is offloaded.
* free during a "translate everything" build and during per-import patches.
* Network/DB work (fetching official Habbo translations, calling LibreTranslate,
* writing files) stays on the main thread; only the synchronous CPU is offloaded.
*
* Degrades gracefully: in the test environment (or if the worker can't be
* created) getTranslationWorker() returns null and callers run the same pure
@@ -30,11 +30,32 @@ interface FinalizeReq {
lang: string;
translations: Map<string, string>;
}
type Req = InitReq | PrepareReq | FinalizeReq;
interface PatchPrepareReq {
id: number;
type: "patchPrepare";
lang: string;
hotel: string;
fileContent: string | null;
entries: PatchEntry[];
}
interface PatchFinalizeReq {
id: number;
type: "patchFinalize";
lang: string;
translations: Map<string, string>;
}
type Req =
| InitReq
| PrepareReq
| FinalizeReq
| PatchPrepareReq
| PatchFinalizeReq;
type ReqWithoutId =
| Omit<InitReq, "id">
| Omit<PrepareReq, "id">
| Omit<FinalizeReq, "id">;
| Omit<FinalizeReq, "id">
| Omit<PatchPrepareReq, "id">
| Omit<PatchFinalizeReq, "id">;
type Res =
| {
@@ -44,12 +65,15 @@ type Res =
json?: string;
translatedRoom?: number;
translatedWall?: number;
patched?: number;
added?: number;
total?: number;
}
| { id: number; ok: false; error: string };
let worker: Worker | null = null;
let broken = false;
let initialized = false;
let nextId = 1;
const pending = new Map<
number,
@@ -84,18 +108,41 @@ function spawn(): Worker | null {
function call(req: ReqWithoutId): Promise<Res> {
if (!worker) return Promise.reject(new Error("no translation worker"));
const w = worker;
return new Promise<Res>((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker!.postMessage({ ...req, id } as Req);
w.postMessage({ ...req, id } as Req);
});
}
/**
* Read the master FurnitureData + each hotel's official translation map once and
* initialize the worker. Idempotent — safe to call before every operation.
* Reads happen on the main thread but only once per process, so the per-import
* CPU cost of parsing the 100k-entry master is amortized away.
*/
async function ensureInit(): Promise<void> {
if (!worker || initialized) return;
const { readFurniData } = await import("@/lib/services/furni-data");
const { fetchTranslationsForHotel, FURNIDATA_LANGUAGES } = await import(
"@/lib/services/furni-data-i18n"
);
const master = (await readFurniData()) as Record<string, unknown>;
const hotels = [...new Set(FURNIDATA_LANGUAGES.map((l) => l.hotel))];
const hotelMaps: Record<string, Map<string, Translation>> = {};
for (const h of hotels) hotelMaps[h] = await fetchTranslationsForHotel(h);
const res = await call({
type: "init",
masterJson: JSON.stringify(master),
hotelMaps,
});
if (!res.ok) throw new Error(res.error);
initialized = true;
}
export interface TranslationWorker {
init(
masterJson: string,
hotelMaps: Record<string, Map<string, Translation>>,
): Promise<void>;
ensureInit(): Promise<void>;
prepare(lang: string, hotel: string): Promise<string[]>;
finalize(
lang: string,
@@ -106,6 +153,16 @@ export interface TranslationWorker {
translatedWall: number;
total: number;
}>;
patchPrepare(
fileContent: string | null,
lang: string,
hotel: string,
entries: PatchEntry[],
): Promise<string[]>;
patchFinalize(
lang: string,
translations: Map<string, string>,
): Promise<{ json: string; patched: number; added: number; total: number }>;
}
export function getTranslationWorker(): TranslationWorker | null {
@@ -113,11 +170,11 @@ export function getTranslationWorker(): TranslationWorker | null {
if (!worker) worker = spawn();
if (!worker) return null;
return {
async init(masterJson, hotelMaps) {
const res = await call({ type: "init", masterJson, hotelMaps });
if (!res.ok) throw new Error(res.error);
async ensureInit() {
await ensureInit();
},
async prepare(lang, hotel) {
await ensureInit();
const res = await call({ type: "prepare", lang, hotel });
if (!res.ok || !res.pendingTexts)
throw new Error(res.ok ? "missing pendingTexts" : res.error);
@@ -134,5 +191,29 @@ export function getTranslationWorker(): TranslationWorker | null {
total: res.total ?? 0,
};
},
async patchPrepare(fileContent, lang, hotel, entries) {
await ensureInit();
const res = await call({
type: "patchPrepare",
lang,
hotel,
fileContent,
entries,
});
if (!res.ok || !res.pendingTexts)
throw new Error(res.ok ? "missing pendingTexts" : res.error);
return res.pendingTexts;
},
async patchFinalize(lang, translations) {
const res = await call({ type: "patchFinalize", lang, translations });
if (!res.ok || res.json === undefined)
throw new Error(res.ok ? "missing json" : res.error);
return {
json: res.json,
patched: res.patched ?? 0,
added: res.added ?? 0,
total: res.total ?? 0,
};
},
};
}
+59 -6
View File
@@ -3,8 +3,12 @@ import type { HabboGamedataHotel } from "@/lib/habbo-gamedata-hotel";
import {
type FurniDataMaster,
finalizeLangFile,
finalizePatch,
type PatchEntry,
type PreparedLang,
type PreparedPatch,
prepareLangFile,
preparePatch,
type Translation,
} from "./furni-data-i18n-core";
@@ -26,19 +30,40 @@ interface FinalizeMsg {
lang: string;
translations: Map<string, string>;
}
type Msg = InitMsg | PrepareMsg | FinalizeMsg;
interface PatchPrepareMsg {
id: number;
type: "patchPrepare";
lang: string;
hotel: string;
fileContent: string | null;
entries: PatchEntry[];
}
interface PatchFinalizeMsg {
id: number;
type: "patchFinalize";
lang: string;
translations: Map<string, string>;
}
type Msg =
| InitMsg
| PrepareMsg
| FinalizeMsg
| PatchPrepareMsg
| PatchFinalizeMsg;
let master: FurniDataMaster | null = null;
let hotelMaps: Record<string, Map<string, Translation>> = {};
const prepared = new Map<string, PreparedLang>();
const preparedPatches = new Map<string, PreparedPatch>();
if (parentPort) {
parentPort.on("message", (msg: Msg) => {
const port = parentPort;
port.on("message", (msg: Msg) => {
try {
if (msg.type === "init") {
master = JSON.parse(msg.masterJson);
hotelMaps = msg.hotelMaps ?? {};
parentPort!.postMessage({ id: msg.id, ok: true });
port.postMessage({ id: msg.id, ok: true });
return;
}
if (msg.type === "prepare") {
@@ -52,21 +77,49 @@ if (parentPort) {
officialMap,
);
prepared.set(msg.lang, r);
parentPort!.postMessage({
port.postMessage({
id: msg.id,
ok: true,
pendingTexts: r.pendingTexts,
});
return;
}
if (msg.type === "patchPrepare") {
if (!master) throw new Error("translation worker not initialized");
const officialMap =
hotelMaps[msg.hotel] ?? new Map<string, Translation>();
const r = preparePatch(
msg.fileContent,
master,
msg.lang,
msg.hotel as HabboGamedataHotel,
officialMap,
msg.entries,
);
preparedPatches.set(msg.lang, r);
port.postMessage({
id: msg.id,
ok: true,
pendingTexts: [...r.pendingTexts],
});
return;
}
if (msg.type === "patchFinalize") {
const r = preparedPatches.get(msg.lang);
if (!r) throw new Error(`no prepared patch for ${msg.lang}`);
const res = finalizePatch(r, msg.translations);
preparedPatches.delete(msg.lang);
port.postMessage({ id: msg.id, ok: true, ...res });
return;
}
// finalize
const r = prepared.get(msg.lang);
if (!r) throw new Error(`no prepared data for ${msg.lang}`);
const res = finalizeLangFile(r.cloned, r.pendingRefs, msg.translations);
prepared.delete(msg.lang);
parentPort!.postMessage({ id: msg.id, ok: true, ...res });
port.postMessage({ id: msg.id, ok: true, ...res });
} catch (err) {
parentPort!.postMessage({
port.postMessage({
id: msg.id,
ok: false,
error: (err as Error).message,