Files
Oxicloud/static/js/core/i18n.js
T

278 lines
8.6 KiB
JavaScript
Raw Normal View History

2025-03-17 21:28:08 +01:00
/**
* OxiCloud Internationalization (i18n) Module
2026-04-07 22:48:59 +02:00
*
2025-03-17 21:28:08 +01:00
* This module provides functionality for internationalization of the OxiCloud web interface.
* It loads translations from the server and provides functions to translate keys.
*/
2026-02-08 22:44:42 +01:00
// Supported locales (languages that have locale files on the server)
// Keep in sync with AVAILABLE_LOCALES in core/languageSelector.js
const supportedLocales = ['en', 'es', 'zh', 'zh-TW', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl'];
// Resolve the best supported locale from a browser language list.
// Priority: exact full-tag (zh-TW) > Chinese script/region heuristics > primary subtag (zh)
function resolveBrowserLocale() {
const browserLangs = navigator.languages || [navigator.language || 'en'];
const lowerSupported = supportedLocales.map((l) => l.toLowerCase());
for (const bl of browserLangs) {
const idx = lowerSupported.indexOf(bl.toLowerCase());
if (idx !== -1) return supportedLocales[idx];
}
for (const bl of browserLangs) {
const tag = bl.toLowerCase();
if (!tag.startsWith('zh')) continue;
const isTraditional = tag.includes('hant') || /\b(tw|hk|mo)\b/.test(tag);
const target = isTraditional ? 'zh-TW' : 'zh';
if (supportedLocales.includes(target)) return target;
}
for (const bl of browserLangs) {
const primary = bl.substring(0, 2).toLowerCase();
if (supportedLocales.includes(primary)) return primary;
}
return 'en';
2025-03-17 21:28:08 +01:00
}
// Current locale code (overridden by saved preference in initI18n)
let currentLocale = resolveBrowserLocale();
2025-03-17 21:28:08 +01:00
// Cache for translations
2026-05-07 23:40:02 +02:00
/** @type {Record<string, any>} */
2025-03-17 21:28:08 +01:00
const translations = {};
/**
* Load translations for a specific locale
* @param {string} locale - The locale code to load (e.g., 'en', 'es')
* @returns {Promise<object>} - A promise that resolves to the translations object
*/
async function loadTranslations(locale) {
// Check if already loaded
if (translations[locale]) {
return translations[locale];
}
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
try {
2025-03-28 09:03:04 +01:00
// Load directly from local JSON file
2025-03-17 21:28:08 +01:00
const localeData = await fetch(`/locales/${locale}.json`);
if (!localeData.ok) {
throw new Error(`Failed to load locale file for ${locale}`);
}
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
translations[locale] = await localeData.json();
return translations[locale];
} catch (error) {
console.error('Error loading translations:', error);
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
// Return empty object as last resort
translations[locale] = {};
return translations[locale];
}
}
/**
* Get a nested translation value
2026-05-07 23:40:02 +02:00
* @param {Record<string, any>} obj - The translations object
2025-03-17 21:28:08 +01:00
* @param {string} path - The dot-notation path to the translation
* @returns {string|null} - The translation value or null if not found
*/
function getNestedValue(obj, path) {
2025-03-28 09:03:04 +01:00
// Try direct key match first
if (obj && typeof obj === 'object' && path in obj) {
const value = obj[path];
2026-04-07 22:48:59 +02:00
return typeof value === 'string' ? value : null;
2025-03-28 09:03:04 +01:00
}
2026-04-07 22:48:59 +02:00
2025-03-28 09:03:04 +01:00
// Try standard dot notation for nested values
2025-03-17 21:28:08 +01:00
const keys = path.split('.');
let current = obj;
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
2025-03-28 09:03:04 +01:00
// Key not found in standard dotted path
// Try a last attempt with underscore format if this is a prefix_suffix format key
if (path.includes('_') && !path.includes('.')) {
const [prefix, ...parts] = path.split('_');
const suffix = parts.join('_');
2026-04-07 22:48:59 +02:00
2025-03-28 09:03:04 +01:00
if (obj[prefix] && typeof obj[prefix] === 'object' && suffix in obj[prefix]) {
return obj[prefix][suffix];
}
}
2025-03-17 21:28:08 +01:00
return null;
}
}
2026-04-07 22:48:59 +02:00
return typeof current === 'string' ? current : null;
2025-03-17 21:28:08 +01:00
}
/**
* Replace parameters in a translation string
* @param {string} text - The translation string with placeholders
2026-05-07 23:40:02 +02:00
* @param {Record<string, any>} params - The parameters to replace
2025-03-17 21:28:08 +01:00
* @returns {string} - The interpolated string
*/
function interpolate(text, params) {
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key) => {
return params[key.trim()] !== undefined ? params[key.trim()] : `{{${key}}}`;
});
}
/**
* Change the current locale
* @param {string} locale - The locale code to switch to
* @returns {Promise<boolean>} - A promise that resolves to true if successful
*/
async function setLocale(locale) {
if (!supportedLocales.includes(locale)) {
console.error(`Locale not supported: ${locale}`);
return false;
}
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
// Load translations if not loaded yet
if (!translations[locale]) {
await loadTranslations(locale);
}
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
// Update current locale
currentLocale = locale;
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
// Save locale preference
localStorage.setItem('oxicloud-locale', locale);
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
// Trigger an event for components to update
window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } }));
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
// Update all elements with data-i18n attribute
translatePage();
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
return true;
}
/**
* Initialize the i18n system
* @returns {Promise<void>}
*/
async function initI18n() {
// Load saved locale preference
const savedLocale = localStorage.getItem('oxicloud-locale');
if (savedLocale && supportedLocales.includes(savedLocale)) {
currentLocale = savedLocale;
}
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
// Load translations for current locale
await loadTranslations(currentLocale);
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
// Preload English translations as fallback
if (currentLocale !== 'en') {
await loadTranslations('en');
}
// Mark loaded BEFORE translatePage so t() resolves properly
translationsLoaded = true;
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
// Translate the page
translatePage();
2026-04-07 22:48:59 +02:00
2025-03-17 21:28:08 +01:00
console.log(`I18n initialized with locale: ${currentLocale}`);
}
/**
* Translate all elements with data-i18n attribute
*/
function translatePage() {
translateElement(document);
}
/**
* Translate only elements within a given root (scoped).
* Use this instead of translatePage() when you know which container changed.
* @param {Element|Document} root - The root element to search within
*/
function translateElement(root) {
const resolve = t;
const el = root || document;
2026-04-07 22:48:59 +02:00
el.querySelectorAll('[data-i18n]').forEach((element) => {
2025-03-17 21:28:08 +01:00
const key = element.getAttribute('data-i18n');
element.textContent = resolve(key);
2025-03-17 21:28:08 +01:00
});
2026-04-07 22:48:59 +02:00
el.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
2025-03-17 21:28:08 +01:00
const key = element.getAttribute('data-i18n-placeholder');
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
element.placeholder = resolve(key);
}
2025-03-17 21:28:08 +01:00
});
2026-04-07 22:48:59 +02:00
el.querySelectorAll('[data-i18n-title]').forEach((element) => {
2025-03-17 21:28:08 +01:00
const key = element.getAttribute('data-i18n-title');
if (element instanceof HTMLElement) {
element.title = resolve(key);
}
2025-03-17 21:28:08 +01:00
});
}
/**
* Get current locale
* @returns {string} - The current locale code
*/
function getCurrentLocale() {
return currentLocale;
}
/**
* Get list of supported locales
* @returns {Array<string>} - Array of supported locale codes
*/
function getSupportedLocales() {
return [...supportedLocales];
}
2025-03-27 01:13:34 +01:00
// Flag to track if translations are loaded
let translationsLoaded = false;
2025-03-17 21:28:08 +01:00
// Initialize when DOM is ready
2025-03-27 01:13:34 +01:00
document.addEventListener('DOMContentLoaded', async () => {
await initI18n();
// translationsLoaded already set inside initI18n
2025-03-27 01:13:34 +01:00
// Dispatch an event when translations are fully loaded
window.dispatchEvent(new Event('translationsLoaded'));
});
2026-05-07 23:40:02 +02:00
/**
* @param {string} key
* @param {string | Record<string, any>} [paramsOrFallback] - interpolation params object, or a string fallback used when the key is missing
* @returns {string}
*/
function t(key, paramsOrFallback = {}) {
2026-05-07 23:40:02 +02:00
const fallback = typeof paramsOrFallback === 'string' ? paramsOrFallback : null;
const params = typeof paramsOrFallback === 'object' ? paramsOrFallback : {};
const localeData = translations[currentLocale];
if (!localeData) {
2026-05-07 23:40:02 +02:00
// Translations not loaded yet — return fallback or humanised key suffix
return fallback ?? key.split('.').pop() ?? key;
2025-03-27 01:13:34 +01:00
}
let value = getNestedValue(localeData, key);
// Fallback to English
2026-04-07 22:50:42 +02:00
if (!value && currentLocale !== 'en' && translations.en) {
value = getNestedValue(translations.en, key);
}
2026-05-07 23:40:02 +02:00
if (!value) return fallback ?? key;
return interpolate(value, params);
2025-03-27 01:13:34 +01:00
}
2025-03-17 21:28:08 +01:00
export const i18n = {
t,
2025-03-17 21:28:08 +01:00
setLocale,
getCurrentLocale,
getSupportedLocales,
2025-03-27 01:13:34 +01:00
translatePage,
translateElement,
2025-03-27 01:13:34 +01:00
isLoaded: () => translationsLoaded
2025-04-14 19:42:29 +08:00
};