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,
|
reconcileFurniDataWithItemsBase,
|
||||||
verifyAndFixSpriteIds,
|
verifyAndFixSpriteIds,
|
||||||
} from "@/lib/services/furni-data";
|
} from "@/lib/services/furni-data";
|
||||||
|
import { buildLocalizedFurniDataFiles } from "@/lib/services/furni-data-i18n";
|
||||||
import {
|
import {
|
||||||
ensureFurniOwnership,
|
ensureFurniOwnership,
|
||||||
reconcileImportedOfferIds,
|
reconcileImportedOfferIds,
|
||||||
@@ -147,6 +148,11 @@ export const POST = withAdmin(
|
|||||||
await rebuildCatalogOfferIds();
|
await rebuildCatalogOfferIds();
|
||||||
await verifyAndFixSpriteIds();
|
await verifyAndFixSpriteIds();
|
||||||
await verifyAndFixInteractionModesCount();
|
await verifyAndFixInteractionModesCount();
|
||||||
|
try {
|
||||||
|
await buildLocalizedFurniDataFiles();
|
||||||
|
} catch {
|
||||||
|
/* localized build failures are non-fatal */
|
||||||
|
}
|
||||||
await ensureFurniOwnership();
|
await ensureFurniOwnership();
|
||||||
await syncAssetsToGamedataBundle();
|
await syncAssetsToGamedataBundle();
|
||||||
await rcon.updateCatalog();
|
await rcon.updateCatalog();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
reconcileFurniDataWithItemsBase,
|
reconcileFurniDataWithItemsBase,
|
||||||
verifyAndFixSpriteIds,
|
verifyAndFixSpriteIds,
|
||||||
} from "@/lib/services/furni-data";
|
} from "@/lib/services/furni-data";
|
||||||
|
import { buildLocalizedFurniDataFiles } from "@/lib/services/furni-data-i18n";
|
||||||
import {
|
import {
|
||||||
downloadFile,
|
downloadFile,
|
||||||
ensureDirectories,
|
ensureDirectories,
|
||||||
@@ -277,6 +278,18 @@ export const POST = withAdmin(
|
|||||||
// Repair any sprite_id drift so the client renders the right item
|
// Repair any sprite_id drift so the client renders the right item
|
||||||
const spriteVerify = await verifyAndFixSpriteIds();
|
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({
|
send({
|
||||||
type: "regen_complete",
|
type: "regen_complete",
|
||||||
succeeded,
|
succeeded,
|
||||||
@@ -293,6 +306,7 @@ export const POST = withAdmin(
|
|||||||
offerRebuildFixed: offerRebuild.fixed,
|
offerRebuildFixed: offerRebuild.fixed,
|
||||||
spriteChecked: spriteVerify.checked,
|
spriteChecked: spriteVerify.checked,
|
||||||
spriteFixed: spriteVerify.fixed,
|
spriteFixed: spriteVerify.fixed,
|
||||||
|
languages,
|
||||||
duration: Date.now() - startTime,
|
duration: Date.now() - startTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
reconcileFurniDataWithItemsBase,
|
reconcileFurniDataWithItemsBase,
|
||||||
verifyAndFixSpriteIds,
|
verifyAndFixSpriteIds,
|
||||||
} from "@/lib/services/furni-data";
|
} from "@/lib/services/furni-data";
|
||||||
|
import { buildLocalizedFurniDataFiles } from "@/lib/services/furni-data-i18n";
|
||||||
import {
|
import {
|
||||||
ensureDirectories,
|
ensureDirectories,
|
||||||
ensureFurniOwnership,
|
ensureFurniOwnership,
|
||||||
@@ -232,6 +233,9 @@ export const POST = withAdmin(
|
|||||||
let offerRebuildFixed = 0;
|
let offerRebuildFixed = 0;
|
||||||
let spriteFixed = 0;
|
let spriteFixed = 0;
|
||||||
let interactionFixed = 0;
|
let interactionFixed = 0;
|
||||||
|
let languages: Awaited<
|
||||||
|
ReturnType<typeof buildLocalizedFurniDataFiles>
|
||||||
|
> = [];
|
||||||
try {
|
try {
|
||||||
offerIdsFixed = (await reconcileImportedOfferIds()).fixed;
|
offerIdsFixed = (await reconcileImportedOfferIds()).fixed;
|
||||||
ownershipFixed = (await ensureFurniOwnership()).fixed;
|
ownershipFixed = (await ensureFurniOwnership()).fixed;
|
||||||
@@ -246,6 +250,14 @@ export const POST = withAdmin(
|
|||||||
offerRebuildFixed = (await rebuildCatalogOfferIds()).fixed;
|
offerRebuildFixed = (await rebuildCatalogOfferIds()).fixed;
|
||||||
spriteFixed = (await verifyAndFixSpriteIds()).fixed;
|
spriteFixed = (await verifyAndFixSpriteIds()).fixed;
|
||||||
interactionFixed = (await verifyAndFixInteractionModesCount()).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) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[import-furni] Post-import reconcile/ownership failed:",
|
"[import-furni] Post-import reconcile/ownership failed:",
|
||||||
@@ -269,6 +281,7 @@ export const POST = withAdmin(
|
|||||||
offerRebuildFixed,
|
offerRebuildFixed,
|
||||||
spriteFixed,
|
spriteFixed,
|
||||||
interactionFixed,
|
interactionFixed,
|
||||||
|
languages,
|
||||||
duration: Date.now() - startTime,
|
duration: Date.now() - startTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
reconcileFurniDataWithItemsBase,
|
reconcileFurniDataWithItemsBase,
|
||||||
verifyAndFixSpriteIds,
|
verifyAndFixSpriteIds,
|
||||||
} from "@/lib/services/furni-data";
|
} from "@/lib/services/furni-data";
|
||||||
|
import {
|
||||||
|
buildLocalizedFurniDataFiles,
|
||||||
|
patchLocalizedFurniDataEntries,
|
||||||
|
} from "@/lib/services/furni-data-i18n";
|
||||||
import {
|
import {
|
||||||
autoPriceFurni,
|
autoPriceFurni,
|
||||||
CATEGORY_PAGE,
|
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
|
// Missing nitro endpoint: returns only imported items missing .nitro files
|
||||||
if (action === "missing-nitro") {
|
if (action === "missing-nitro") {
|
||||||
const pageNum = parseInt(
|
const pageNum = parseInt(
|
||||||
@@ -471,6 +480,18 @@ export const POST = withAdmin(
|
|||||||
// Verify + fix interaction_modes_count once for the whole batch
|
// Verify + fix interaction_modes_count once for the whole batch
|
||||||
const verification = await verifyAndFixInteractionModesCount();
|
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 succeeded = results.filter((r) => r.ok).length;
|
||||||
const failed = 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,
|
spriteChecked: spriteVerify.checked,
|
||||||
spriteFixed: spriteVerify.fixed,
|
spriteFixed: spriteVerify.fixed,
|
||||||
interactionFixed: verification.fixed,
|
interactionFixed: verification.fixed,
|
||||||
|
languages,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +584,21 @@ export const POST = withAdmin(
|
|||||||
// Verify + fix interaction_modes_count (single consolidated pass)
|
// Verify + fix interaction_modes_count (single consolidated pass)
|
||||||
const verification = await verifyAndFixInteractionModesCount();
|
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({
|
logAudit({
|
||||||
userId: ctx.session.user.id,
|
userId: ctx.session.user.id,
|
||||||
action: "furni_import",
|
action: "furni_import",
|
||||||
@@ -595,6 +632,7 @@ export const POST = withAdmin(
|
|||||||
spriteChecked: spriteVerify.checked,
|
spriteChecked: spriteVerify.checked,
|
||||||
spriteFixed: spriteVerify.fixed,
|
spriteFixed: spriteVerify.fixed,
|
||||||
interactionFixed: verification.fixed,
|
interactionFixed: verification.fixed,
|
||||||
|
languages: languagesPatch,
|
||||||
spriteCount: result.spriteCount,
|
spriteCount: result.spriteCount,
|
||||||
warnings: result.warnings,
|
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