228 lines
8.9 KiB
JavaScript
228 lines
8.9 KiB
JavaScript
// 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();
|
|
}
|
|
})();
|