From 6ae7c09682b06d4c771545cd6e3264a62dc34d62 Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 24 Aug 2026 19:29:21 +0200 Subject: [PATCH] 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). --- src/lib/services/furni-data-i18n-core.ts | 239 +++++++++++++++++++ src/lib/services/furni-data-i18n.ts | 286 +++++++---------------- src/lib/services/furni-data.ts | 29 ++- src/lib/services/translation-pool.ts | 109 +++++++-- src/lib/services/translation-worker.ts | 65 +++++- 5 files changed, 502 insertions(+), 226 deletions(-) diff --git a/src/lib/services/furni-data-i18n-core.ts b/src/lib/services/furni-data-i18n-core.ts index 4674ac8576..4fec66fe80 100644 --- a/src/lib/services/furni-data-i18n-core.ts +++ b/src/lib/services/furni-data-i18n-core.ts @@ -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; + 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, + 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(); + 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(); + 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(); + 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, +): FinalizedPatch { + const { data, refs, newItems, officialPatched } = prepared; + + const counted = new Set(); + 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, + }; +} diff --git a/src/lib/services/furni-data-i18n.ts b/src/lib/services/furni-data-i18n.ts index 906b7d20ad..f8a4cef0f6 100644 --- a/src/lib/services/furni-data-i18n.ts +++ b/src/lib/services/furni-data-i18n.ts @@ -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> { const cached = langCache.get(hotel); @@ -400,16 +404,12 @@ export async function buildLocalizedFurniDataFiles( { furnitype: Array> } >; - // Fetch each hotel's official translations once (network + per-hotel cache). - const hotels = [...new Set(langsToBuild.map((l) => l.hotel))]; - const hotelMaps: Record> = {}; - 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(); @@ -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> } - >; - const maps = await Promise.all( - langsToBuild.map(async ({ lang, hotel }) => { - const map = await fetchTranslationsForHotel(hotel); - return { lang, hotel, map }; - }), - ); - - const wanted = new Map(); - 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(); 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> }>; + // 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; - field: "name" | "description"; - }> = []; - const pendingTexts = new Set(); - 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>(); - 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(); - 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; - name?: string; - description?: string; - translateName: boolean; - translateDesc: boolean; - }> = []; - const newTexts = new Set(); - for (const { classname, name, description } of entries) { - if (existingClassnames.has(classname)) continue; - let found: Record | undefined; - let foundSection: "roomitemtypes" | "wallitemtypes" | undefined; - for (const section of ["roomitemtypes", "wallitemtypes"] as const) { - const hit = ( - master[section]?.furnitype as - | Array> - | undefined - )?.find((e) => e.classname === classname) as - | Record - | 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; - 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) { diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 9cccecbacb..b6c48245fa 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -199,10 +199,34 @@ export async function withFurniDataLock(fn: () => Promise): Promise { } } +let cachedFurniData: { + path: string; + mtimeMs: number; + size: number; + data: Record; +} | null = null; + export async function readFurniData(): Promise> { 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; + 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: { diff --git a/src/lib/services/translation-pool.ts b/src/lib/services/translation-pool.ts index 8b9b7d7661..3c08a86bc7 100644 --- a/src/lib/services/translation-pool.ts +++ b/src/lib/services/translation-pool.ts @@ -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; } -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; +} +type Req = + | InitReq + | PrepareReq + | FinalizeReq + | PatchPrepareReq + | PatchFinalizeReq; type ReqWithoutId = | Omit | Omit - | Omit; + | Omit + | Omit + | Omit; 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 { if (!worker) return Promise.reject(new Error("no translation worker")); + const w = worker; return new Promise((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 { + 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; + const hotels = [...new Set(FURNIDATA_LANGUAGES.map((l) => l.hotel))]; + const hotelMaps: Record> = {}; + 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>, - ): Promise; + ensureInit(): Promise; prepare(lang: string, hotel: string): Promise; 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; + patchFinalize( + lang: string, + translations: Map, + ): 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, + }; + }, }; } diff --git a/src/lib/services/translation-worker.ts b/src/lib/services/translation-worker.ts index 2a653a955d..1644f9bb39 100644 --- a/src/lib/services/translation-worker.ts +++ b/src/lib/services/translation-worker.ts @@ -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; } -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; +} +type Msg = + | InitMsg + | PrepareMsg + | FinalizeMsg + | PatchPrepareMsg + | PatchFinalizeMsg; let master: FurniDataMaster | null = null; let hotelMaps: Record> = {}; const prepared = new Map(); +const preparedPatches = new Map(); 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(); + 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,