Files
EpicNext-Cms/src/lib/services/furni-data-i18n.ts
T
openhands c7ba04bda1
CI / check (push) Failing after 26s
CI / release (push) Skipped
CI / deploy (push) Skipped
feat: relocate import tools into studio and harden catalog/furnidata integrity
- Move /admin/import/* tools under /admin/studio/* and add studio nav, layout and tabs
- Add shared catalog maintenance panel plus a /admin/studio/maintenance tab
- Make furnidata reconciliation overwrite conflicting entries with the
  DB-authoritative items_base classname/id (surfaced via fixedConflicts)
- Add furnidata_translate_enabled setting to skip the heavy localized
  furnidata build during import (manual build-languages still forces it)
- Update staff smoke contract test for the studio hub
2026-08-24 17:13:03 +02:00

805 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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"];
/**
* Master switch for FurnitureData localization. Rebuilding the per-language
* files deep-clones the 100k+ entry master 13× and hits LibreTranslate, which
* can spike RAM/CPU on every import. Admins can disable it in settings to skip
* the work entirely (the master FurnitureData.json is still written by the
* import itself). The manual "build languages" action passes `force` to run
* regardless.
*/
async function translationEnabled(): Promise<boolean> {
try {
return await siteSettings.getBool("furnidata_translate_enabled", true);
} catch {
return true;
}
}
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;
}
}
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<string>,
targetLang: string,
): Promise<Map<string, string>> {
const result = new Map<string, string>();
if (targetLang === "en") return result;
const cache = await loadLibreCache();
const pending = new Set<string>();
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<void> => {
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<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;
}
/**
* 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<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(
force = false,
): Promise<LanguageFileResult[]> {
if (!force && !(await translationEnabled())) {
logger.debug(
"[furni-i18n] FurnitureData translation disabled by setting; skipping localized build.",
);
return [];
}
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>,
);
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<string, unknown>;
field: "name" | "description";
section: "roomitemtypes" | "wallitemtypes";
}> = [];
const pendingTexts = new Set<string>();
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 (officialIsReal && t?.name) {
entry.name = t.name;
} else if (
lang !== "en" &&
typeof entry.name === "string" &&
entry.name.trim()
) {
pendingTexts.add(entry.name.trim());
pendingRefs.push({ entry, field: "name", section });
}
if (officialIsReal && t?.description) {
entry.description = t.description;
} else if (
lang !== "en" &&
typeof entry.description === "string" &&
entry.description.trim()
) {
pendingTexts.add(entry.description.trim());
pendingRefs.push({ entry, field: "description", section });
}
}
}
// 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<Record<string, unknown>>();
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++;
}
}
}
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 }>,
force = false,
): Promise<LanguageFileResult[]> {
if (entries.length === 0) return [];
if (!force && !(await translationEnabled())) {
logger.debug(
"[furni-i18n] FurnitureData translation disabled by setting; skipping localized patch.",
);
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 officialIsReal = officialIsRealTranslation(lang, hotel);
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;
// 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;
}
}
}
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 (officialIsReal && 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 (officialIsReal && 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 (officialIsReal && t?.name) clone.name = t.name;
else if (lang !== "en") {
const src =
name ?? (typeof found.name === "string" ? found.name : "") ?? "";
if (src) {
const translated = await translateWithLibre(src, lang);
if (translated !== src) clone.name = translated;
}
}
if (officialIsReal && t?.description)
clone.description = t.description;
else if (lang !== "en") {
const src =
description ??
(typeof found.description === "string"
? found.description
: "") ??
"";
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);
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 };
}