- Add EMULATOR_MODE=api option with HTTP transport mirroring RCON payload - buildLocalizedFurniDataFiles now falls back to LibreTranslate for customs (incl. pl/ru/ar/ja) - Remove Organiseer import button from studio and Reorganize Catalog from import page - Delete organize API route and PUT handler; remove organizeCatalogAll from catalog-repair.ts - Increase imported-furni list perPage from 50 to 500
637 lines
20 KiB
TypeScript
637 lines
20 KiB
TypeScript
/**
|
|
* Localized furnidata builder — generates FurnitureData_<lang>.json files
|
|
* (nl/en/de/fr/es/tr/it/pt/fi/pl/ru/ar/ja) from the master FurnitureData.json
|
|
* + official Habbo translations per hotel (where available) + LibreTranslate
|
|
* fallback for customs.
|
|
*
|
|
* 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 { existsSync, 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 { getGamedataRoot } from "@/lib/services/furni-asset-dirs";
|
|
import {
|
|
getFurnitureDataWritePaths,
|
|
readFurniData,
|
|
withFurniDataLock,
|
|
} from "@/lib/services/furni-data";
|
|
import { browserHeaders } from "@/lib/services/import/core/browser-headers";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
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 },
|
|
{ lang: "pt", hotel: "com.br" as HabboGamedataHotel },
|
|
{ lang: "fi", hotel: "fi" as HabboGamedataHotel },
|
|
{ lang: "pl", hotel: "com" as HabboGamedataHotel },
|
|
{ lang: "ru", hotel: "com" as HabboGamedataHotel },
|
|
{ lang: "ar", hotel: "com" as HabboGamedataHotel },
|
|
{ lang: "ja", hotel: "com" 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>();
|
|
|
|
// ── LibreTranslate (gratis, self-hosted) ─────────────────────────────
|
|
const LIBRE_DEFAULT_URL = "http://127.0.0.1:5000";
|
|
const LIBRE_CACHE_FILE = ".translations_libre_cache.json";
|
|
type LibreCache = Record<string, string>; // key: `${target}|${text}` → translated
|
|
|
|
let libreMemoryCache: Map<string, string> | null = null;
|
|
let libreCacheLoaded = false;
|
|
|
|
async function getLibreTranslateUrl(): Promise<string> {
|
|
try {
|
|
const v = (await siteSettings.get(
|
|
"libretranslate_url",
|
|
LIBRE_DEFAULT_URL,
|
|
)) as string | null;
|
|
const url = (v ?? LIBRE_DEFAULT_URL).trim() || LIBRE_DEFAULT_URL;
|
|
return url.replace(/\/+$/, "");
|
|
} catch {
|
|
return LIBRE_DEFAULT_URL;
|
|
}
|
|
}
|
|
|
|
async function loadLibreCache(): Promise<Map<string, string>> {
|
|
if (libreMemoryCache && libreCacheLoaded) return libreMemoryCache;
|
|
try {
|
|
const gamedataRoot = await getGamedataRoot();
|
|
const cachePath = gamedataRoot
|
|
? path.join(gamedataRoot, "config", LIBRE_CACHE_FILE)
|
|
: path.join(process.cwd(), LIBRE_CACHE_FILE);
|
|
if (existsSync(cachePath)) {
|
|
const raw = await fs.readFile(cachePath, "utf-8");
|
|
const obj = JSON.parse(raw) as LibreCache;
|
|
libreMemoryCache = new Map(Object.entries(obj));
|
|
} else {
|
|
libreMemoryCache = new Map();
|
|
}
|
|
} catch {
|
|
libreMemoryCache = new Map();
|
|
}
|
|
libreCacheLoaded = true;
|
|
return libreMemoryCache ?? new Map<string, string>();
|
|
}
|
|
|
|
async function saveLibreCache(): Promise<void> {
|
|
if (!libreMemoryCache) return;
|
|
try {
|
|
const gamedataRoot = await getGamedataRoot();
|
|
const cachePath = gamedataRoot
|
|
? path.join(gamedataRoot, "config", LIBRE_CACHE_FILE)
|
|
: path.join(process.cwd(), LIBRE_CACHE_FILE);
|
|
const obj: LibreCache = Object.fromEntries(libreMemoryCache.entries());
|
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
await fs.writeFile(cachePath, JSON.stringify(obj), "utf-8");
|
|
} catch (err) {
|
|
logger.warn("[furni-i18n] Failed to save Libre cache", {
|
|
error: (err as Error).message,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function translateWithLibre(
|
|
text: string,
|
|
targetLang: string,
|
|
): Promise<string> {
|
|
const trimmed = text.trim();
|
|
if (!trimmed || targetLang === "en") return text;
|
|
const cache = await loadLibreCache();
|
|
const key = `${targetLang}|${trimmed}`;
|
|
const cached = cache.get(key);
|
|
if (cached !== undefined) return cached;
|
|
|
|
const url = await getLibreTranslateUrl();
|
|
try {
|
|
const res = await fetch(`${url}/translate`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
q: trimmed,
|
|
source: "en",
|
|
target: targetLang,
|
|
format: "text",
|
|
}),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (!res.ok) {
|
|
logger.warn(
|
|
`[furni-i18n] LibreTranslate ${targetLang} failed: ${res.status}`,
|
|
);
|
|
return text;
|
|
}
|
|
const data = (await res.json()) as { translatedText?: string };
|
|
const out =
|
|
typeof data.translatedText === "string" ? data.translatedText : text;
|
|
cache.set(key, out);
|
|
// Best-effort persist, don't await per-call to avoid hammering disk — save
|
|
// in background throttled (every 50 new entries).
|
|
if (cache.size % 50 === 0) void saveLibreCache();
|
|
return out;
|
|
} catch (err) {
|
|
logger.warn(`[furni-i18n] LibreTranslate error (${targetLang})`, {
|
|
error: (err as Error).message,
|
|
});
|
|
return text;
|
|
}
|
|
}
|
|
|
|
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;
|
|
// 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;
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
|
|
// Persist the LibreTranslate cache once per language so the next
|
|
// full build reuses translations instead of re-calling the API.
|
|
if (lang !== "en") await saveLibreCache();
|
|
|
|
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 — official only to avoid
|
|
// 100k+ LibreTranslate calls on first creation).
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 : "";
|
|
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 (t?.name) {
|
|
entry.name = t.name;
|
|
didPatch = true;
|
|
} else if (lang !== "en" && origName) {
|
|
const translated = await translateWithLibre(origName, lang);
|
|
if (translated && translated !== origName) {
|
|
entry.name = translated;
|
|
didPatch = true;
|
|
}
|
|
}
|
|
if (t?.description) {
|
|
entry.description = t.description;
|
|
didPatch = true;
|
|
} else if (lang !== "en" && origDesc) {
|
|
const translated = await translateWithLibre(origDesc, lang);
|
|
if (translated && translated !== origDesc) {
|
|
entry.description = translated;
|
|
didPatch = true;
|
|
}
|
|
}
|
|
if (didPatch) 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, name, description } 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;
|
|
else if (lang !== "en") {
|
|
const src =
|
|
name ?? (typeof found.name === "string" ? found.name : "") ?? "";
|
|
if (src) clone.name = await translateWithLibre(src, lang);
|
|
}
|
|
if (t?.description) clone.description = t.description;
|
|
else if (lang !== "en") {
|
|
const src =
|
|
description ??
|
|
(typeof found.description === "string"
|
|
? found.description
|
|
: "") ??
|
|
"";
|
|
if (src) clone.description = await translateWithLibre(src, lang);
|
|
}
|
|
if (!data[foundSection]) data[foundSection] = { furnitype: [] };
|
|
data[foundSection].furnitype.push(clone);
|
|
patched++;
|
|
}
|
|
|
|
// Persist LibreTranslate cache once per language (batched).
|
|
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.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 };
|
|
}
|