From 8bb339cc49979cabbc7070a40725904c17ee94ef Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 21 Aug 2026 14:16:37 +0200 Subject: [PATCH] feat: auto-translate furnidata per import (nl/en/de/fr/es/tr/it) Generate localized FurnitureData_.json files from the master FurnitureData.json + official Habbo hotel translations. Fetches per-hotel furnidata with in-memory cache (6h TTL), maps by classname (* variant aware), and overrides name/description where a translation exists. Full rebuild after every batch/batch-regen/clone import and as admin GET ?action=build-languages; single imports patch incrementally via patchLocalizedFurniDataEntries. Failures are best-effort and never fail the import itself. Files are written to every configured gamedata write path (primary + mirrors) so /gamedata/config/FurnitureData_.json is available for per-language Nitro clients. New service: src/lib/services/furni-data-i18n.ts with buildLocalizedFurniDataFiles, patchLocalizedFurniDataEntries, FURNIDATA_LANGUAGES (nl/en/de/fr/es/tr/it) and applyTranslationsForTest. --- src/app/api/admin/import/clone/batch/route.ts | 6 + .../admin/import/furni/batch-regen/route.ts | 14 + src/app/api/admin/import/furni/batch/route.ts | 13 + src/app/api/admin/import/furni/route.ts | 38 ++ src/lib/services/furni-data-i18n.ts | 448 ++++++++++++++++++ 5 files changed, 519 insertions(+) create mode 100644 src/lib/services/furni-data-i18n.ts diff --git a/src/app/api/admin/import/clone/batch/route.ts b/src/app/api/admin/import/clone/batch/route.ts index 6c5aaedd..ce19b688 100644 --- a/src/app/api/admin/import/clone/batch/route.ts +++ b/src/app/api/admin/import/clone/batch/route.ts @@ -14,6 +14,7 @@ import { reconcileFurniDataWithItemsBase, verifyAndFixSpriteIds, } from "@/lib/services/furni-data"; +import { buildLocalizedFurniDataFiles } from "@/lib/services/furni-data-i18n"; import { ensureFurniOwnership, reconcileImportedOfferIds, @@ -147,6 +148,11 @@ export const POST = withAdmin( await rebuildCatalogOfferIds(); await verifyAndFixSpriteIds(); await verifyAndFixInteractionModesCount(); + try { + await buildLocalizedFurniDataFiles(); + } catch { + /* localized build failures are non-fatal */ + } await ensureFurniOwnership(); await syncAssetsToGamedataBundle(); await rcon.updateCatalog(); diff --git a/src/app/api/admin/import/furni/batch-regen/route.ts b/src/app/api/admin/import/furni/batch-regen/route.ts index e68cbddf..75db57e0 100644 --- a/src/app/api/admin/import/furni/batch-regen/route.ts +++ b/src/app/api/admin/import/furni/batch-regen/route.ts @@ -14,6 +14,7 @@ import { reconcileFurniDataWithItemsBase, verifyAndFixSpriteIds, } from "@/lib/services/furni-data"; +import { buildLocalizedFurniDataFiles } from "@/lib/services/furni-data-i18n"; import { downloadFile, ensureDirectories, @@ -277,6 +278,18 @@ export const POST = withAdmin( // Repair any sprite_id drift so the client renders the right item const spriteVerify = await verifyAndFixSpriteIds(); + let languages: Awaited< + ReturnType + > = []; + try { + languages = await buildLocalizedFurniDataFiles(); + } catch (err) { + console.warn( + "[batch-regen] Localized furnidata build failed:", + (err as Error).message, + ); + } + send({ type: "regen_complete", succeeded, @@ -293,6 +306,7 @@ export const POST = withAdmin( offerRebuildFixed: offerRebuild.fixed, spriteChecked: spriteVerify.checked, spriteFixed: spriteVerify.fixed, + languages, duration: Date.now() - startTime, }); diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts index 179d762c..9cae964a 100644 --- a/src/app/api/admin/import/furni/batch/route.ts +++ b/src/app/api/admin/import/furni/batch/route.ts @@ -9,6 +9,7 @@ import { reconcileFurniDataWithItemsBase, verifyAndFixSpriteIds, } from "@/lib/services/furni-data"; +import { buildLocalizedFurniDataFiles } from "@/lib/services/furni-data-i18n"; import { ensureDirectories, ensureFurniOwnership, @@ -232,6 +233,9 @@ export const POST = withAdmin( let offerRebuildFixed = 0; let spriteFixed = 0; let interactionFixed = 0; + let languages: Awaited< + ReturnType + > = []; try { offerIdsFixed = (await reconcileImportedOfferIds()).fixed; ownershipFixed = (await ensureFurniOwnership()).fixed; @@ -246,6 +250,14 @@ export const POST = withAdmin( offerRebuildFixed = (await rebuildCatalogOfferIds()).fixed; spriteFixed = (await verifyAndFixSpriteIds()).fixed; interactionFixed = (await verifyAndFixInteractionModesCount()).fixed; + try { + languages = await buildLocalizedFurniDataFiles(); + } catch (err) { + console.warn( + "[import-furni] Localized furnidata build failed:", + (err as Error).message, + ); + } } catch (err) { console.warn( "[import-furni] Post-import reconcile/ownership failed:", @@ -269,6 +281,7 @@ export const POST = withAdmin( offerRebuildFixed, spriteFixed, interactionFixed, + languages, duration: Date.now() - startTime, }); diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index a2007949..370a0fcf 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -14,6 +14,10 @@ import { reconcileFurniDataWithItemsBase, verifyAndFixSpriteIds, } from "@/lib/services/furni-data"; +import { + buildLocalizedFurniDataFiles, + patchLocalizedFurniDataEntries, +} from "@/lib/services/furni-data-i18n"; import { autoPriceFurni, CATEGORY_PAGE, @@ -189,6 +193,11 @@ export const GET = withAdmin( }); } + if (action === "build-languages") { + const languages = await buildLocalizedFurniDataFiles(); + return apiOk({ languages }); + } + // Missing nitro endpoint: returns only imported items missing .nitro files if (action === "missing-nitro") { const pageNum = parseInt( @@ -471,6 +480,18 @@ export const POST = withAdmin( // Verify + fix interaction_modes_count once for the whole batch const verification = await verifyAndFixInteractionModesCount(); + // Rebuild all localized furnidata files (best effort) + let languages: Awaited> = + []; + try { + languages = await buildLocalizedFurniDataFiles(); + } catch (err) { + console.warn( + "[import-furni] Localized furnidata build failed:", + (err as Error).message, + ); + } + const succeeded = results.filter((r) => r.ok).length; const failed = results.filter((r) => !r.ok).length; @@ -492,6 +513,7 @@ export const POST = withAdmin( spriteChecked: spriteVerify.checked, spriteFixed: spriteVerify.fixed, interactionFixed: verification.fixed, + languages, }); } @@ -562,6 +584,21 @@ export const POST = withAdmin( // Verify + fix interaction_modes_count (single consolidated pass) const verification = await verifyAndFixInteractionModesCount(); + // Patch localized furnidata files for this single furni (best effort) + let languagesPatch: Awaited< + ReturnType + > = []; + try { + languagesPatch = await patchLocalizedFurniDataEntries([ + { classname, name, description: description ?? "" }, + ]); + } catch (err) { + console.warn( + "[import-furni] Localized patch failed:", + (err as Error).message, + ); + } + logAudit({ userId: ctx.session.user.id, action: "furni_import", @@ -595,6 +632,7 @@ export const POST = withAdmin( spriteChecked: spriteVerify.checked, spriteFixed: spriteVerify.fixed, interactionFixed: verification.fixed, + languages: languagesPatch, spriteCount: result.spriteCount, warnings: result.warnings, }); diff --git a/src/lib/services/furni-data-i18n.ts b/src/lib/services/furni-data-i18n.ts new file mode 100644 index 00000000..ae54d396 --- /dev/null +++ b/src/lib/services/furni-data-i18n.ts @@ -0,0 +1,448 @@ +/** + * Localized furnidata builder — generates FurnitureData_.json files + * (nl/en/de/fr/es/tr/it) from the master FurnitureData.json + official + * Habbo translations per hotel. + * + * On each import we rebuild per-language files so new/updated furniture also + * gets its translated names. Official translations are fetched from Habbo's + * public gamedata (habbo.nl, habbo.com, habbo.de …) and cached in memory. + */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { HabboGamedataHotel } from "@/lib/habbo-gamedata-hotel"; +import { habboFurnidataUrl } from "@/lib/habbo-gamedata-hotel"; +import { logger } from "@/lib/logger"; +import { + getFurnitureDataWritePaths, + readFurniData, + withFurniDataLock, +} from "@/lib/services/furni-data"; +import { browserHeaders } from "@/lib/services/import/core/browser-headers"; + +export const FURNIDATA_LANGUAGES = [ + { lang: "nl", hotel: "nl" as HabboGamedataHotel }, + { lang: "en", hotel: "com" as HabboGamedataHotel }, + { lang: "de", hotel: "de" as HabboGamedataHotel }, + { lang: "fr", hotel: "fr" as HabboGamedataHotel }, + { lang: "es", hotel: "es" as HabboGamedataHotel }, + { lang: "tr", hotel: "com.tr" as HabboGamedataHotel }, + { lang: "it", hotel: "it" as HabboGamedataHotel }, +] as const; + +export type FurnidataLang = (typeof FURNIDATA_LANGUAGES)[number]["lang"]; + +export interface LanguageFileResult { + lang: string; + hotel: string; + file: string; + translatedRoom: number; + translatedWall: number; + total: number; + ok: boolean; + error?: string; +} + +type Translation = { name: string; description: string }; + +const LANG_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours — translations rarely change + +interface LangCacheEntry { + map: Map; + timestamp: number; + loading?: Promise>; +} + +const langCache = new Map(); + +function lookupTranslation( + map: Map, + 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; +} + +async function fetchTranslationsForHotel( + hotel: HabboGamedataHotel, +): Promise> { + const cached = langCache.get(hotel); + const now = Date.now(); + if (cached && !cached.loading && now - cached.timestamp < LANG_CACHE_TTL) { + return cached.map; + } + if (cached?.loading) return cached.loading; + + const url = habboFurnidataUrl(hotel); + + const loading = (async () => { + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(20_000), + headers: browserHeaders(), + }); + if (!res.ok) { + logger.warn( + `[furni-i18n] Fetch failed for habbo.${hotel}: ${res.status}`, + ); + return cached?.map ?? new Map(); + } + const data = await res.json(); + const map = new Map(); + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + const arr = data?.[section]?.furnitype; + if (!Array.isArray(arr)) continue; + for (const e of arr) { + const cn = typeof e?.classname === "string" ? e.classname : ""; + if (!cn) continue; + // Keep first occurrence — mirrors the official file order. + if (map.has(cn)) continue; + map.set(cn, { + name: typeof e?.name === "string" ? e.name : "", + description: + typeof e?.description === "string" ? e.description : "", + }); + } + } + langCache.set(hotel, { map, timestamp: Date.now() }); + logger.debug( + `[furni-i18n] Cached ${map.size} translations from habbo.${hotel}`, + ); + return map; + } catch (err) { + logger.warn(`[furni-i18n] Fetch error for habbo.${hotel}`, { + error: (err as Error).message, + }); + return cached?.map ?? new Map(); + } + })(); + + langCache.set(hotel, { + map: cached?.map ?? new Map(), + timestamp: cached?.timestamp ?? 0, + loading, + }); + const result = await loading; + // Clear loading flag but keep map+timestamp. + const entry = langCache.get(hotel); + if (entry) { + entry.loading = undefined; + entry.map = result; + if (!entry.timestamp) entry.timestamp = Date.now(); + } + return result; +} + +async function getLocalizedWritePaths(lang: string): Promise { + const bases = await getFurnitureDataWritePaths(); + const out: string[] = []; + for (const base of bases) { + const dir = path.dirname(base); + const langFile = `FurnitureData_${lang}.json`; + out.push(path.join(dir, langFile)); + } + // Deduplicate same normalized path. + const seen = new Set(); + return out.filter((p) => { + const key = process.platform === "win32" ? p.toLowerCase() : p; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function cloneFurniData( + data: Record, +): Record> }> { + // Deep clone via JSON — structure is pure JSON and 100k+ entries, this is + // the simplest safe copy. Each language build gets its own clone. + return JSON.parse(JSON.stringify(data)) as Record< + string, + { furnitype: Array> } + >; +} + +/** + * Build all language files from the current master FurnitureData.json. + * Each file is a full copy with name/description overridden where an official + * translation exists for that classname. Runs under the furnidata lock so it + * never interleaves with concurrent imports. + */ +export async function buildLocalizedFurniDataFiles(): Promise< + LanguageFileResult[] +> { + return withFurniDataLock(async () => { + const master = (await readFurniData()) as Record< + string, + { furnitype: Array> } + >; + + // Fetch all language maps in parallel (uses per-hotel memory cache). + const maps = await Promise.all( + FURNIDATA_LANGUAGES.map(async ({ lang, hotel }) => { + const map = await fetchTranslationsForHotel(hotel); + return { lang, hotel, map }; + }), + ); + + const results: LanguageFileResult[] = []; + + for (const { lang, hotel, map } of maps) { + try { + const cloned = cloneFurniData( + master as unknown as Record, + ); + let translatedRoom = 0; + let translatedWall = 0; + + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + const arr = cloned[section]?.furnitype; + if (!Array.isArray(arr)) continue; + 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) { + entry.name = t.name; + didTranslate = true; + } + if (t.description) { + entry.description = t.description; + didTranslate = true; + } + if (didTranslate) { + if (section === "roomitemtypes") translatedRoom++; + else translatedWall++; + } + } + } + + const total = + (cloned.roomitemtypes?.furnitype?.length ?? 0) + + (cloned.wallitemtypes?.furnitype?.length ?? 0); + + const payload = JSON.stringify(cloned); + const targets = await getLocalizedWritePaths(lang); + 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.rename(tmp, target); + } + + results.push({ + lang, + hotel, + file: targets[0] ?? `FurnitureData_${lang}.json`, + translatedRoom, + translatedWall, + total, + ok: true, + }); + } catch (err) { + results.push({ + lang, + hotel, + file: `FurnitureData_${lang}.json`, + translatedRoom: 0, + translatedWall: 0, + total: 0, + ok: false, + error: (err as Error).message, + }); + } + } + + return results; + }); +} + +/** + * Patch the language files for a small set of classnames (e.g. a single + * import) without rebuilding every file from scratch. Used on single-import + * paths where a full rebuild would be wasteful. + */ +export async function patchLocalizedFurniDataEntries( + entries: Array<{ classname: string; name?: string; description?: string }>, +): Promise { + if (entries.length === 0) return []; + + return withFurniDataLock(async () => { + const maps = await Promise.all( + FURNIDATA_LANGUAGES.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 results: LanguageFileResult[] = []; + + for (const { lang, hotel, map } of maps) { + try { + const targets = await getLocalizedWritePaths(lang); + // Load the existing language file if present, otherwise clone master. + let data: Record> }>; + try { + const raw = await fs.readFile(targets[0], "utf-8"); + data = JSON.parse(raw); + } catch { + const master = (await readFurniData()) as Record< + string, + { furnitype: Array> } + >; + data = cloneFurniData(master as unknown as Record); + // Apply all translations for the fallback path (same as full build + // but only for the file that didn't exist). + 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) continue; + if (t.name) entry.name = t.name; + if (t.description) entry.description = t.description; + } + } + } + + let patched = 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 (!wanted.has(cn)) continue; + const t = lookupTranslation(map, cn); + if (!t) continue; + if (t.name) entry.name = t.name; + if (t.description) entry.description = t.description; + patched++; + } + } + // Also handle brand-new entries that weren't in the file yet (rare + // when a single import added a classname not previously in lang file + // because the file was stale). Append with translated names. + 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); + } + } + // New classnames are already in the master; if our loaded data + // came from an old lang file before the import, the entry won't + // exist — copy it from master with translation applied. + const master = (await readFurniData()) as Record< + string, + { furnitype: Array> } + >; + for (const { classname } of entries) { + if (existingClassnames.has(classname)) continue; + // Find in master. + let found: Record | undefined; + let foundSection: "roomitemtypes" | "wallitemtypes" | undefined; + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + const arr = master[section]?.furnitype; + const hit = arr?.find((e) => e.classname === classname) as + | Record + | undefined; + if (hit) { + found = hit; + foundSection = section; + break; + } + } + if (!found || !foundSection) continue; + const clone = { ...found }; + const t = lookupTranslation(map, classname); + if (t?.name) clone.name = t.name; + if (t?.description) clone.description = t.description; + if (!data[foundSection]) data[foundSection] = { furnitype: [] }; + data[foundSection].furnitype.push(clone); + patched++; + } + + 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.rename(tmp, target); + } + + results.push({ + lang, + hotel, + file: targets[0] ?? `FurnitureData_${lang}.json`, + translatedRoom: patched, + translatedWall: 0, + total: patched, + ok: true, + }); + } catch (err) { + results.push({ + lang, + hotel, + file: `FurnitureData_${lang}.json`, + translatedRoom: 0, + translatedWall: 0, + total: 0, + ok: false, + error: (err as Error).message, + }); + } + } + + return results; + }); +} + +/** Visible for tests — apply translations to a furnidata clone in place. */ +export function applyTranslationsForTest( + data: Record> }>, + translations: Map, +): { translatedRoom: number; translatedWall: number } { + let translatedRoom = 0; + let translatedWall = 0; + for (const section of ["roomitemtypes", "wallitemtypes"] as const) { + for (const entry of data[section]?.furnitype ?? []) { + const cn = typeof entry.classname === "string" ? entry.classname : ""; + const t = lookupTranslation(translations, cn); + if (!t) continue; + let did = false; + if (t.name) { + entry.name = t.name; + did = true; + } + if (t.description) { + entry.description = t.description; + did = true; + } + if (did) { + if (section === "roomitemtypes") translatedRoom++; + else translatedWall++; + } + } + } + return { translatedRoom, translatedWall }; +}