@@ -0,0 +1,227 @@
|
||||
// Client-side vehicle name translator.
|
||||
//
|
||||
// Loads the localized vehicle name map produced by the bot's
|
||||
// init_vehicle_translation_cache() (BOT/utils.py) and exposes:
|
||||
//
|
||||
// 1. window.vehicleI18n.translate(internal, fallback, lang?)
|
||||
// Synchronous lookup. Returns the localized name when available,
|
||||
// otherwise English, otherwise the supplied fallback.
|
||||
//
|
||||
// 2. window.vehicleI18n.apply(root?)
|
||||
// Walks `root` (defaults to <body>) and rewrites the textContent of
|
||||
// every element carrying `data-vehicle-internal="<cdk>"` to the
|
||||
// localized name. The element's *original* text is captured into
|
||||
// `data-vehicle-fallback` on first apply so re-applies (e.g. after
|
||||
// lang switch) don't lose the fallback.
|
||||
//
|
||||
// On startup the module:
|
||||
// - Eagerly loads the multilang map (via window.apiClient — /api/* is
|
||||
// gated by the website's apiSecurityCheck middleware).
|
||||
// - On first successful load + DOMContentLoaded, runs apply(document.body)
|
||||
// and installs a MutationObserver so any future inserts auto-translate.
|
||||
//
|
||||
// Server-side EJS or client-side template strings only need to add the
|
||||
// data attribute — no per-page wiring required.
|
||||
|
||||
(function () {
|
||||
const STORAGE_KEY = 'vehicleI18nCache_v2';
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let _map = null; // { internal: { en, ru, ... } }
|
||||
let _source = null; // 'multilang' | 'english_only' | 'none'
|
||||
let _loadingPromise = null;
|
||||
let _observerInstalled = false;
|
||||
|
||||
function readCache() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !parsed.fetchedAt || (Date.now() - parsed.fetchedAt) > TTL_MS) return null;
|
||||
if (parsed.source !== 'multilang') return null;
|
||||
return parsed.vehicles || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(vehicles, source) {
|
||||
if (source !== 'multilang') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ fetchedAt: Date.now(), source, vehicles }));
|
||||
} catch (e) {
|
||||
// quota exceeded or storage disabled
|
||||
}
|
||||
}
|
||||
|
||||
function fireReady(detail) {
|
||||
// Defer to next tick so any listeners installed after this module
|
||||
// executes still see the event.
|
||||
setTimeout(() => document.dispatchEvent(new CustomEvent('vehicle-i18n-ready', { detail })), 0);
|
||||
}
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (_map) return _map;
|
||||
const cached = readCache();
|
||||
if (cached) {
|
||||
_map = cached;
|
||||
_source = 'multilang';
|
||||
fireReady({ source: 'multilang', cached: true });
|
||||
return _map;
|
||||
}
|
||||
if (_loadingPromise) return _loadingPromise;
|
||||
_loadingPromise = (async () => {
|
||||
try {
|
||||
if (!window.apiClient) {
|
||||
await new Promise((resolve) => {
|
||||
const tick = () => window.apiClient ? resolve() : setTimeout(tick, 50);
|
||||
tick();
|
||||
});
|
||||
}
|
||||
const body = await window.apiClient.request('/api/i18n/vehicles');
|
||||
_map = body && body.vehicles ? body.vehicles : {};
|
||||
_source = body && body.source ? body.source : 'none';
|
||||
writeCache(_map, _source);
|
||||
fireReady({ source: _source });
|
||||
return _map;
|
||||
} catch (e) {
|
||||
console.error('vehicle i18n load failed', e);
|
||||
_map = {};
|
||||
_source = 'none';
|
||||
return _map;
|
||||
} finally {
|
||||
_loadingPromise = null;
|
||||
}
|
||||
})();
|
||||
return _loadingPromise;
|
||||
}
|
||||
|
||||
function currentLang() {
|
||||
const m = (document.cookie || '').match(/(?:^|;\s*)lang=([\w-]+)/);
|
||||
return (m && m[1]) || (document.documentElement.lang || 'en');
|
||||
}
|
||||
|
||||
// Case-insensitive map lookup. The DB historically stored mixed casings of
|
||||
// vehicle_internal (e.g. germ_leopard_I vs germ_leopard_i), and the API
|
||||
// now lowercases them; the bot's translation cache may or may not match
|
||||
// that casing depending on its source. Try the literal key first, then
|
||||
// lowercase, then a one-time-built lowercased index for everything else.
|
||||
let _lowerIndex = null;
|
||||
function lookup(internal) {
|
||||
if (!_map || !internal) return null;
|
||||
if (_map[internal]) return _map[internal];
|
||||
const lower = String(internal).toLowerCase();
|
||||
if (_map[lower]) return _map[lower];
|
||||
if (!_lowerIndex) {
|
||||
_lowerIndex = {};
|
||||
for (const k of Object.keys(_map)) _lowerIndex[k.toLowerCase()] = _map[k];
|
||||
}
|
||||
return _lowerIndex[lower] || null;
|
||||
}
|
||||
|
||||
function translate(internal, fallback, lang) {
|
||||
const lng = lang || currentLang();
|
||||
if (!_map || !internal) return fallback || internal || '';
|
||||
const entry = lookup(internal);
|
||||
if (!entry) return fallback || internal;
|
||||
const localized = entry[lng] || entry.en || fallback || internal;
|
||||
// The player-rendered display the DB stores can have a leading
|
||||
// country-leak / event glyph (▄ ◘ ◢ ␗ etc.) that WT's client prepends
|
||||
// at draw time. The translation map only knows the canonical name
|
||||
// without that prefix, so naive replacement would lose it — making
|
||||
// e.g. "▄F-16A ADF" (Italy) and "F-16A ADF" (no leak) look identical.
|
||||
// Preserve any leading run of non-letter / non-digit / non-space chars
|
||||
// from the original fallback when the translation doesn't already
|
||||
// include it.
|
||||
if (fallback) {
|
||||
const m = String(fallback).match(/^[^\p{L}\p{N}\s]+/u);
|
||||
if (m && !localized.startsWith(m[0])) return m[0] + localized;
|
||||
}
|
||||
return localized;
|
||||
}
|
||||
|
||||
function applyToElement(el) {
|
||||
if (!el || !el.dataset || !el.dataset.vehicleInternal) return;
|
||||
const internal = el.dataset.vehicleInternal;
|
||||
if (!internal) return;
|
||||
// Capture the rendered fallback once so language re-switches still have
|
||||
// something to fall back on if the map ever loses an entry.
|
||||
if (el.dataset.vehicleFallback === undefined) {
|
||||
el.dataset.vehicleFallback = el.textContent || '';
|
||||
}
|
||||
const lng = currentLang();
|
||||
// Cache the lang we last applied so we don't fight the DOM on every
|
||||
// mutation tick.
|
||||
if (el.dataset.vehicleAppliedLang === lng) return;
|
||||
el.textContent = translate(internal, el.dataset.vehicleFallback, lng);
|
||||
el.dataset.vehicleAppliedLang = lng;
|
||||
}
|
||||
|
||||
function apply(root) {
|
||||
if (!_map) return;
|
||||
const node = root || document.body;
|
||||
if (!node) return;
|
||||
if (node.nodeType === 1 && node.dataset && node.dataset.vehicleInternal) {
|
||||
applyToElement(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll('[data-vehicle-internal]').forEach(applyToElement);
|
||||
}
|
||||
}
|
||||
|
||||
function installMutationObserver() {
|
||||
if (_observerInstalled || typeof MutationObserver === 'undefined' || !document.body) return;
|
||||
_observerInstalled = true;
|
||||
const obs = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1) apply(node);
|
||||
}
|
||||
if (m.type === 'attributes' && m.target && m.target.dataset && m.target.dataset.vehicleInternal) {
|
||||
delete m.target.dataset.vehicleAppliedLang;
|
||||
applyToElement(m.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['data-vehicle-internal'],
|
||||
});
|
||||
}
|
||||
|
||||
function autoApply() {
|
||||
if (!document.body) return;
|
||||
apply(document.body);
|
||||
installMutationObserver();
|
||||
}
|
||||
|
||||
document.addEventListener('vehicle-i18n-ready', autoApply);
|
||||
|
||||
window.vehicleI18n = {
|
||||
ensureLoaded,
|
||||
translate,
|
||||
apply,
|
||||
get ready() { return _map !== null; },
|
||||
get source() { return _source; },
|
||||
get currentLang() { return currentLang(); },
|
||||
invalidate() {
|
||||
try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
|
||||
_map = null;
|
||||
_source = null;
|
||||
_lowerIndex = null;
|
||||
// Also clear apply-state markers so a refresh re-translates.
|
||||
document.querySelectorAll('[data-vehicle-applied-lang]').forEach(el => {
|
||||
delete el.dataset.vehicleAppliedLang;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Kick off load. apply() runs from the ready event handler above.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', ensureLoaded);
|
||||
} else {
|
||||
ensureLoaded();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user