feat: i18n system - French/English internationalization
Core: i18n.js module with t(), setLocale(), initI18n() - Flat key→string JSON locale files (fr.json + en.json, 165 keys) - data-i18n / data-i18n-placeholder / data-i18n-attr DOM attributes - Live locale switch via setLocale() - no page reload - Language detection: localStorage > navigator.language > 'fr' - Fallback: missing key → French → raw key Wired into: - app.js: await initI18n() early in init() - config.js: profile save → await setLocale(), toasts + confirm via t() - auth.js: network error toast via t() - index.html: data-i18n on login form, profile section, search placeholder
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
import * as UI from './ui.js';
|
||||
import * as Utils from './utils.js';
|
||||
import { initI18n } from './i18n.js';
|
||||
|
||||
// Wire up AI toolbar toast (avoids circular import in utils.js)
|
||||
window._obsigateShowToast = UI.showToast;
|
||||
@@ -23,6 +24,7 @@ import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
|
||||
// =========================================================================
|
||||
async function init() {
|
||||
UI.initTheme();
|
||||
await initI18n(); // Must run before any rendering that uses t()
|
||||
UI.initHeaderMenu();
|
||||
UI.initCustomDropdowns();
|
||||
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu } from './ui.js';
|
||||
import { t } from './i18n.js';
|
||||
import { showWelcome } from './viewer.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -33,7 +34,7 @@ async function api(path, opts) {
|
||||
res = await fetch(path, mergedOpts);
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") throw err; // let callers handle abort
|
||||
showToast("Erreur réseau — vérifiez votre connexion", "error");
|
||||
showToast(t("auth.network_error"), "error");
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 401 && AuthManager._authEnabled) {
|
||||
|
||||
+10
-8
@@ -5,6 +5,7 @@ import { el, icon, openFile } from './viewer.js';
|
||||
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { escapeHtml, safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
import { t, setLocale, getLocale } from './i18n.js';
|
||||
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
@@ -769,7 +770,7 @@ function saveFrontendConfig() {
|
||||
search_timeout_ms: _getFieldNum("cfg-timeout", 30000),
|
||||
};
|
||||
localStorage.setItem(_FRONTEND_CONFIG_KEY, JSON.stringify(cfg));
|
||||
showToast("Paramètres client sauvegardés", "success");
|
||||
showToast(t("toast.config_client_saved"), "success");
|
||||
}
|
||||
|
||||
async function saveBackendConfig() {
|
||||
@@ -793,7 +794,7 @@ async function saveBackendConfig() {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast("Configuration backend sauvegardée", "success");
|
||||
showToast(t("toast.config_saved"), "success");
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
showToast(errorData.detail || "Erreur de sauvegarde", "error");
|
||||
@@ -812,7 +813,7 @@ async function forceReindex() {
|
||||
}
|
||||
try {
|
||||
await api("/api/index/reload");
|
||||
showToast("Réindexation terminée", "success");
|
||||
showToast(t("toast.reindex_done"), "success");
|
||||
loadDiagnostics();
|
||||
await Promise.all([loadVaults(), loadTags()]);
|
||||
} catch (err) {
|
||||
@@ -853,7 +854,7 @@ async function resetConfigDefaults() {
|
||||
console.error("Reset config error:", err);
|
||||
}
|
||||
loadConfigFields();
|
||||
showToast("Configuration réinitialisée", "success");
|
||||
showToast(t("toast.config_reset"), "success");
|
||||
}
|
||||
|
||||
async function loadDiagnostics() {
|
||||
@@ -1549,15 +1550,16 @@ function initProfile() {
|
||||
|
||||
// Load saved profile
|
||||
try {
|
||||
var lang = localStorage.getItem('obsigate-lang') || 'fr';
|
||||
var lang = getLocale();
|
||||
var name = localStorage.getItem('obsigate-name') || '';
|
||||
if (langSel) langSel.value = lang;
|
||||
if (nameInp) nameInp.value = name;
|
||||
} catch(e) {}
|
||||
|
||||
saveBtn.addEventListener('click', function() {
|
||||
saveBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
localStorage.setItem('obsigate-lang', langSel.value);
|
||||
var newLang = langSel.value;
|
||||
await setLocale(newLang); // Live-reload all translations
|
||||
if (nameInp) localStorage.setItem('obsigate-name', nameInp.value.trim());
|
||||
if (savedEl) { savedEl.style.display = 'inline'; setTimeout(function() { savedEl.style.display = 'none'; }, 2000); }
|
||||
} catch(e) {}
|
||||
@@ -1566,7 +1568,7 @@ function initProfile() {
|
||||
// Logout button
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function() {
|
||||
if (confirm('Voulez-vous vraiment vous deconnecter ?')) {
|
||||
if (confirm(t('confirm.logout'))) {
|
||||
fetch('/api/auth/logout', { method: 'POST' })
|
||||
.then(function() { window.location.href = '/'; })
|
||||
.catch(function() { window.location.href = '/'; });
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* Reads saved preference from localStorage, falls back to navigator.language,
|
||||
* then to 'fr'. */
|
||||
export async function initI18n() {
|
||||
// 1. Determine locale: saved > navigator.language > 'fr'
|
||||
let locale = null;
|
||||
try { locale = localStorage.getItem('obsigate-lang'); } catch (e) { /* */ }
|
||||
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 {
|
||||
const resp = await fetch('/static/locales/' + locale + '.json');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user