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:
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user