diff --git a/src/lib/services/furni-data-i18n.ts b/src/lib/services/furni-data-i18n.ts index 61cc18b5..13c3709f 100644 --- a/src/lib/services/furni-data-i18n.ts +++ b/src/lib/services/furni-data-i18n.ts @@ -169,6 +169,114 @@ async function translateWithLibre( } } +const LIBRE_BATCH_SIZE = 48; +const LIBRE_CONCURRENCY = 4; + +/** + * Translate many texts in one go using LibreTranslate's array API + * (q accepts string[] → translatedText: string[]). Deduplicates input, + * serves everything already in the cache, then fetches the rest in + * parallel batches. Returns a map of trimmed source text → translation. + * Texts whose batch failed are simply absent from the result. + */ +async function translateManyWithLibre( + texts: Iterable, + targetLang: string, +): Promise> { + const result = new Map(); + if (targetLang === "en") return result; + + const cache = await loadLibreCache(); + const pending = new Set(); + for (const raw of texts) { + const trimmed = raw.trim(); + if (!trimmed) continue; + const key = `${targetLang}|${trimmed}`; + const cached = cache.get(key); + if (cached !== undefined) { + result.set(trimmed, cached); + continue; + } + pending.add(trimmed); + } + if (pending.size === 0) return result; + + const chunks: string[][] = []; + let current: string[] = []; + for (const text of pending) { + current.push(text); + if (current.length >= LIBRE_BATCH_SIZE) { + chunks.push(current); + current = []; + } + } + if (current.length > 0) chunks.push(current); + + const url = await getLibreTranslateUrl(); + let failures = 0; + let nextChunk = 0; + const worker = async (): Promise => { + while (true) { + const i = nextChunk++; + if (i >= chunks.length) return; + const chunk = chunks[i]; + try { + const res = await fetch(`${url}/translate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + q: chunk, + source: "en", + target: targetLang, + format: "text", + }), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + failures++; + logger.warn( + `[furni-i18n] LibreTranslate batch ${targetLang} failed: ${res.status}`, + ); + continue; + } + const data = (await res.json()) as { + translatedText?: string[] | string; + }; + const out = Array.isArray(data.translatedText) + ? data.translatedText + : typeof data.translatedText === "string" + ? [data.translatedText] + : []; + for (let j = 0; j < chunk.length; j++) { + const translated = + typeof out[j] === "string" && out[j] ? out[j] : chunk[j]; + result.set(chunk[j], translated); + cache.set(`${targetLang}|${chunk[j]}`, translated); + } + } catch (err) { + failures++; + logger.warn(`[furni-i18n] LibreTranslate batch error (${targetLang})`, { + error: (err as Error).message, + }); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(LIBRE_CONCURRENCY, chunks.length) }, () => + worker(), + ), + ); + + if (failures > 0) { + logger.warn( + `[furni-i18n] LibreTranslate ${targetLang}: ${failures}/${chunks.length} batches failed`, + ); + } + // Persist whatever succeeded so rebuilds don't re-call the API. + await saveLibreCache(); + return result; +} + function lookupTranslation( map: Map, classname: string, @@ -189,6 +297,19 @@ function lookupTranslation( return undefined; } +/** + * 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). Shared by the full build and the single-import patch + * so both paths stay in sync. + */ +function officialIsRealTranslation( + lang: string, + hotel: HabboGamedataHotel, +): boolean { + return lang === "en" || hotel !== "com"; +} + async function fetchTranslationsForHotel( hotel: HabboGamedataHotel, ): Promise> { @@ -318,59 +439,65 @@ export async function buildLocalizedFurniDataFiles(): Promise< const cloned = cloneFurniData( master as unknown as Record, ); - let translatedRoom = 0; - let translatedWall = 0; + const officialIsReal = officialIsRealTranslation(lang, hotel); + // Pass 1 — apply official translations directly and collect the + // texts that need LibreTranslate (customs, or languages whose + // hotel only provides English) so they can be batch-translated. + const pendingRefs: Array<{ + entry: Record; + field: "name" | "description"; + section: "roomitemtypes" | "wallitemtypes"; + }> = []; + const pendingTexts = new Set(); 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); - let didTranslate = false; - // 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; - } + pendingTexts.add(entry.name.trim()); + pendingRefs.push({ entry, field: "name", section }); } 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; - } + pendingTexts.add(entry.description.trim()); + pendingRefs.push({ entry, field: "description", section }); } - if (didTranslate) { - if (section === "roomitemtypes") translatedRoom++; + } + } + + // Pass 2 — batch-translate everything pending in a handful of + // parallel requests instead of one HTTP call per text. + const translations = await translateManyWithLibre(pendingTexts, lang); + + let translatedRoom = 0; + let translatedWall = 0; + if (pendingRefs.length > 0) { + const counted = new Set>(); + 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++; } } @@ -445,6 +572,7 @@ export async function patchLocalizedFurniDataEntries( for (const { lang, hotel, map } of maps) { try { + const officialIsReal = officialIsRealTranslation(lang, hotel); const targets = await getLocalizedWritePaths(lang); // Load the existing language file if present, otherwise clone master. let data: Record> }>; @@ -465,6 +593,10 @@ export async function patchLocalizedFurniDataEntries( const cn = typeof entry.classname === "string" ? entry.classname : ""; if (!cn) continue; + // Only real official translations count here — English names + // from a com-mapped hotel must not be written over the file + // as if they were localized. + if (!officialIsReal) continue; const t = lookupTranslation(map, cn); if (t?.name) entry.name = t.name; if (t?.description) entry.description = t.description; @@ -487,7 +619,7 @@ export async function patchLocalizedFurniDataEntries( const origDesc = wantedEntry.description ?? (typeof entry.description === "string" ? entry.description : ""); - if (t?.name) { + if (officialIsReal && t?.name) { entry.name = t.name; didPatch = true; } else if (lang !== "en" && origName) { @@ -497,7 +629,7 @@ export async function patchLocalizedFurniDataEntries( didPatch = true; } } - if (t?.description) { + if (officialIsReal && t?.description) { entry.description = t.description; didPatch = true; } else if (lang !== "en" && origDesc) { @@ -546,13 +678,17 @@ export async function patchLocalizedFurniDataEntries( if (!found || !foundSection) continue; const clone = { ...found }; const t = lookupTranslation(map, classname); - if (t?.name) clone.name = t.name; + if (officialIsReal && t?.name) clone.name = t.name; else if (lang !== "en") { const src = name ?? (typeof found.name === "string" ? found.name : "") ?? ""; - if (src) clone.name = await translateWithLibre(src, lang); + if (src) { + const translated = await translateWithLibre(src, lang); + if (translated !== src) clone.name = translated; + } } - if (t?.description) clone.description = t.description; + if (officialIsReal && t?.description) + clone.description = t.description; else if (lang !== "en") { const src = description ?? @@ -560,7 +696,10 @@ export async function patchLocalizedFurniDataEntries( ? found.description : "") ?? ""; - if (src) clone.description = await translateWithLibre(src, lang); + if (src) { + const translated = await translateWithLibre(src, lang); + if (translated !== src) clone.description = translated; + } } if (!data[foundSection]) data[foundSection] = { furnitype: [] }; data[foundSection].furnitype.push(clone);