feat: auto-translate furnidata per import (nl/en/de/fr/es/tr/it)

Generate localized FurnitureData_<lang>.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_<lang>.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.
This commit is contained in:
openhands committed 2026-08-21 14:16:37 +02:00
1 parent 984b5bf793
commit 8bb339cc49
5 files changed
+519

No files matched your search

@@ -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();
@@ -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<typeof buildLocalizedFurniDataFiles>
> = [];
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,
});
@@ -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<typeof buildLocalizedFurniDataFiles>
> = [];
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,
});
+38
View File
@@ -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<ReturnType<typeof buildLocalizedFurniDataFiles>> =
[];
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<typeof patchLocalizedFurniDataEntries>
> = [];
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,
});
+448
View File
@@ -0,0 +1,448 @@
/**
* Localized furnidata builder — generates FurnitureData_<lang>.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<string, Translation>;
timestamp: number;
loading?: Promise<Map<string, Translation>>;
}
const langCache = new Map<string, LangCacheEntry>();
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;
}
async function fetchTranslationsForHotel(
hotel: HabboGamedataHotel,
): Promise<Map<string, Translation>> {
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<string, Translation>();
}
const data = await res.json();
const map = new Map<string, Translation>();
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<string, Translation>();
}
})();
langCache.set(hotel, {
map: cached?.map ?? new Map<string, Translation>(),
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<string[]> {
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<string>();
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<string, unknown>,
): Record<string, { furnitype: Array<Record<string, unknown>> }> {
// 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<Record<string, unknown>> }
>;
}
/**
* 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<Record<string, unknown>> }
>;
// 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<string, unknown>,
);
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<LanguageFileResult[]> {
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<string, { name?: string; description?: string }>();
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<string, { furnitype: Array<Record<string, unknown>> }>;
try {
const raw = await fs.readFile(targets[0], "utf-8");
data = JSON.parse(raw);
} catch {
const master = (await readFurniData()) as Record<
string,
{ furnitype: Array<Record<string, unknown>> }
>;
data = cloneFurniData(master as unknown as Record<string, unknown>);
// 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<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);
}
}
// 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<Record<string, unknown>> }
>;
for (const { classname } of entries) {
if (existingClassnames.has(classname)) continue;
// Find in master.
let found: Record<string, unknown> | 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<string, unknown>
| 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<string, { furnitype: Array<Record<string, unknown>> }>,
translations: Map<string, Translation>,
): { 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 };
}