Files
ObsiGate/frontend/js/i18n.js
T
bruno 7f0e34d318
CI / lint (push) Successful in 1m9s
CI / security (push) Successful in 43s
CI / test (push) Successful in 1m58s
CI / build (push) Successful in 41s
CI / e2e (push) Successful in 10m31s
fix(ai): liste de modeles corrompue et cles i18n brutes (BUG-009)
Locales chargees en cache no-store + SW_VERSION v4 pour eviter les cles brutes (fr.json obsolete). Picker : styles critiques en ligne (popover/liste/options), plafond de 200 modeles avec indicateur, et non-chevauchement entre la liste de modeles et la bulle de capacites.
2026-09-12 13:15:27 -04:00

196 lines
7.0 KiB
JavaScript

/* 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-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:TITLE → sets any attribute
document.querySelectorAll('[data-i18n-attr]').forEach(function (el) {
const raw = el.getAttribute('data-i18n-attr');
const colon = raw.indexOf(':');
if (colon === -1) return;
const attr = raw.substring(0, colon);
const key = raw.substring(colon + 1);
el.setAttribute(attr, t(key));
});
}