203 lines
7.4 KiB
JavaScript
203 lines
7.4 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-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));
|
|
});
|
|
});
|
|
}
|