/* ObsiGate — i18n (internationalisation). * * Architecture: * Flat key→string JSON locale files in /static/locales/{fr,en,...}.json * Static DOM: data-i18n="key" → textContent * data-i18n-placeholder="key" → placeholder * data-i18n-attr:title="key" → title attribute * data-i18n-attr="a:k1;b:k2" → several attributes (";"-separated) * data-i18n-html="key" → innerHTML (use sparingly) * Dynamic JS: import { t } from './i18n.js'; t('key', {param: 'val'}) * Live reload: setLocale('en') updates every data-i18n element instantly. * * Key naming: section.what — lowercase, dot-separated, descriptive. * e.g. header.search_placeholder button.save error.network * * Usage: * import { t, setLocale, getLocale, initI18n } from './i18n.js'; * await initI18n(); // early, before any rendering * el.textContent = t('button.cancel'); // dynamic string * setLocale('en'); // user switches language */ // ── State ────────────────────────────────────────────────────────────────── let _currentLocale = 'fr'; let _strings = {}; // { key: translated_string } let _fallbackStrings = {}; // FR fallback, loaded once let _loaded = false; // ── Public helpers ───────────────────────────────────────────────────────── /** Return the currently active locale code ('fr', 'en', ...). */ export function getLocale() { return _currentLocale; } /** Translate a key. If params provided, replace {name} placeholders. * Falls back: key → French → key itself. Never throws. */ export function t(key, params) { let s = _strings[key]; if (s === undefined) { s = _fallbackStrings[key]; if (s === undefined) { console.warn('obsigate i18n: missing key', key); s = key; // last resort — show the key so the dev notices } } if (params) { for (const [k, v] of Object.entries(params)) { s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), String(v)); } } return s; } // ── Reverse-lookup cache (French text → key) ──────────────────────────── let _reverseCache = null; function _buildReverseCache() { _reverseCache = {}; for (const [k, v] of Object.entries(_fallbackStrings)) { if (k.startsWith('_')) continue; // Prefer shorter keys when multiple keys map to the same French text if (!_reverseCache[v] || k.length < _reverseCache[v].length) { _reverseCache[v] = k; } } } /** Translate a French string to the current locale by reverse lookup. * "Paramètres client sauvegardés" → fr: same, en: "Client settings saved". * If the string is not found in the FR locale, returns it unchanged. */ export function tr(frenchText) { if (!frenchText || _currentLocale === 'fr') return frenchText; if (!_reverseCache) _buildReverseCache(); var key = _reverseCache[frenchText]; return key ? t(key) : frenchText; } /** Change locale and re-translate the entire DOM. * Persists to localStorage. */ export async function setLocale(locale) { locale = locale || 'fr'; if (!_loaded) { // Too early — store intent and apply after init try { localStorage.setItem('obsigate-lang', locale); } catch (e) { /* */ } return; } await _loadLocaleStrings(locale); _applyDOM(); document.documentElement.lang = locale; try { localStorage.setItem('obsigate-lang', locale); } catch (e) { /* */ } } /** Initialise the i18n system. Call once, early, before any rendering. * Priority: server profile > localStorage > navigator.language > 'fr'. */ export async function initI18n() { // 1. Determine locale: server profile > localStorage > navigator.language > 'fr' let locale = null; // Try server-side language preference (survives clear site data) try { const resp = await fetch('/api/auth/me', { credentials: 'include' }); if (resp.ok) { const user = await resp.json(); if (user.language === 'en' || user.language === 'fr') { locale = user.language; } } } catch (e) { /* not logged in or network error — fall through */ } // Fall back to localStorage if (!locale) { try { locale = localStorage.getItem('obsigate-lang'); } catch (e) { /* */ } } // Fall back to navigator.language if (!locale) { const nav = (navigator.language || '').toLowerCase(); locale = nav.startsWith('fr') ? 'fr' : nav.startsWith('en') ? 'en' : 'fr'; } // 2. Load fallback (FR) — always needed _fallbackStrings = await _fetchLocale('fr'); // 3. Load selected locale await _loadLocaleStrings(locale); // 4. Apply to DOM _applyDOM(); document.documentElement.lang = locale; _loaded = true; } // ── Internal ─────────────────────────────────────────────────────────────── async function _fetchLocale(locale) { try { // ``no-store`` bypasses the HTTP cache: locale files are not content-hashed // and a stale copy would surface raw keys in the UI (missing translations). const resp = await fetch('/static/locales/' + locale + '.json', { cache: 'no-store' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); return await resp.json(); } catch (err) { console.warn('obsigate i18n: failed to load locale ' + locale, err); return {}; } } async function _loadLocaleStrings(locale) { _currentLocale = locale; if (locale === 'fr') { _strings = _fallbackStrings; } else { _strings = await _fetchLocale(locale); } } // ── Global exports for non-module scripts (legacy app.js) ────────────────── window.t = t; window.tr = tr; window.getLocale = getLocale; window.setLocale = setLocale; window.initI18n = initI18n; /** Walk the DOM and update every element with a data-i18n* attribute. */ function _applyDOM() { // textContent document.querySelectorAll('[data-i18n]').forEach(function (el) { const key = el.getAttribute('data-i18n'); el.textContent = t(key); }); // placeholder document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) { const key = el.getAttribute('data-i18n-placeholder'); el.setAttribute('placeholder', t(key)); }); // innerHTML (use sparingly — XSS risk if keys ever hold user content) document.querySelectorAll('[data-i18n-html]').forEach(function (el) { const key = el.getAttribute('data-i18n-html'); el.innerHTML = t(key); }); // data-i18n-attr:ATTR:key[;ATTR:key…] → sets any attribute(s). // Single-pair form (data-i18n-attr="title:key") is preserved; multiple // pairs are separated with ";" (BUG-071: the config TOC toggle needs both // title and aria-label translated). document.querySelectorAll('[data-i18n-attr]').forEach(function (el) { const raw = el.getAttribute('data-i18n-attr'); raw.split(';').forEach(function (pair) { const colon = pair.indexOf(':'); if (colon === -1) return; const attr = pair.substring(0, colon).trim(); const key = pair.substring(colon + 1).trim(); if (!attr || !key) return; el.setAttribute(attr, t(key)); }); }); }