/** * OxiCloud Internationalization (i18n) Module * * This module provides functionality for internationalization of the OxiCloud web interface. * It loads translations from the server and provides functions to translate keys. */ // Current locale code (default to browser locale if available, fallback to English) let currentLocale = navigator.language?.substring(0, 2) || navigator.userLanguage?.substring(0, 2) || 'en'; // Supported locales (languages that have locale files on the server) // When a locale file is not found, the system gracefully falls back to English const supportedLocales = ['en', 'es', 'zh', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko']; // Fallback to English if locale is not supported if (!supportedLocales.includes(currentLocale)) { currentLocale = 'en'; } // Cache for translations const translations = {}; /** * Load translations for a specific locale * @param {string} locale - The locale code to load (e.g., 'en', 'es') * @returns {Promise} - A promise that resolves to the translations object */ async function loadTranslations(locale) { // Check if already loaded if (translations[locale]) { return translations[locale]; } try { // Load directly from local JSON file const localeData = await fetch(`/locales/${locale}.json`); if (!localeData.ok) { throw new Error(`Failed to load locale file for ${locale}`); } translations[locale] = await localeData.json(); return translations[locale]; } catch (error) { console.error('Error loading translations:', error); // Return empty object as last resort translations[locale] = {}; return translations[locale]; } } /** * Get a nested translation value * @param {object} obj - The translations object * @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) { // Try direct key match first if (obj && typeof obj === 'object' && path in obj) { const value = obj[path]; return typeof value === 'string' ? value : null; } // Try standard dot notation for nested values const keys = path.split('.'); let current = obj; for (const key of keys) { if (current && typeof current === 'object' && key in current) { current = current[key]; } else { // 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('_'); if (obj[prefix] && typeof obj[prefix] === 'object' && suffix in obj[prefix]) { return obj[prefix][suffix]; } } return null; } } return typeof current === 'string' ? current : null; } /** * Translate a key to the current locale * @param {string} key - The translation key (dot notation, e.g., 'app.title') * @param {object} params - Parameters to replace in the translation (e.g., {name: 'John'}) * @returns {string} - The translated string or the key itself if not found */ // biome-ignore lint/correctness/noUnusedVariables: global function function t(key, params = {}) { // Get translation from cache const localeData = translations[currentLocale]; if (!localeData) { // Translation not loaded yet, return key console.warn(`Translations for ${currentLocale} not loaded yet`); return key; } // Special handling for shared_ and share_ prefixed keys if (key.startsWith('shared_') || key.startsWith('share_')) { const unprefixedKey = key.replace(/^(shared|share)_/, ''); const prefixObj = key.startsWith('shared_') ? localeData.shared : localeData.share; if (prefixObj && typeof prefixObj === 'object' && unprefixedKey in prefixObj) { return interpolate(prefixObj[unprefixedKey], params); } } // Get the translation value let value = getNestedValue(localeData, key); // Compatibility aliases for legacy share.* keys used in some views if (!value) { const aliasMap = { 'share.enablePassword': 'share.password', 'share.enableExpiration': 'share.expiration', 'share.notifyEmail': 'share.notifyEmailLabel', 'share.notifyMessage': 'share.notifyMessageLabel' }; const aliasKey = aliasMap[key]; if (aliasKey) { value = getNestedValue(localeData, aliasKey); } } if (!value) { // Try fallback to English if (currentLocale !== 'en' && translations.en) { let fallbackValue = getNestedValue(translations.en, key); if (!fallbackValue) { const aliasMap = { 'share.enablePassword': 'share.password', 'share.enableExpiration': 'share.expiration', 'share.notifyEmail': 'share.notifyEmailLabel', 'share.notifyMessage': 'share.notifyMessageLabel' }; const aliasKey = aliasMap[key]; if (aliasKey) { fallbackValue = getNestedValue(translations.en, aliasKey); } } if (fallbackValue) { return interpolate(fallbackValue, params); } } // Key not found, return key console.warn(`Translation key not found: ${key}`); return key; } // Replace parameters return interpolate(value, params); } /** * Replace parameters in a translation string * @param {string} text - The translation string with placeholders * @param {object} params - The parameters to replace * @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} - A promise that resolves to true if successful */ async function setLocale(locale) { if (!supportedLocales.includes(locale)) { console.error(`Locale not supported: ${locale}`); return false; } // Load translations if not loaded yet if (!translations[locale]) { await loadTranslations(locale); } // Update current locale currentLocale = locale; // Save locale preference localStorage.setItem('oxicloud-locale', locale); // Trigger an event for components to update window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } })); // Update all elements with data-i18n attribute translatePage(); return true; } /** * Initialize the i18n system * @returns {Promise} */ async function initI18n() { // Load saved locale preference const savedLocale = localStorage.getItem('oxicloud-locale'); if (savedLocale && supportedLocales.includes(savedLocale)) { currentLocale = savedLocale; } // Load translations for current locale await loadTranslations(currentLocale); // Preload English translations as fallback if (currentLocale !== 'en') { await loadTranslations('en'); } // Mark loaded BEFORE translatePage so safeT resolves properly translationsLoaded = true; // Translate the page translatePage(); 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) { // Use safeT instead of bare t() to avoid issues when other scripts // (e.g. admin.js) shadow the global t() function. const resolve = safeT; const el = root || document; el.querySelectorAll('[data-i18n]').forEach((element) => { const key = element.getAttribute('data-i18n'); element.textContent = resolve(key); }); el.querySelectorAll('[data-i18n-placeholder]').forEach((element) => { const key = element.getAttribute('data-i18n-placeholder'); element.placeholder = resolve(key); }); el.querySelectorAll('[data-i18n-title]').forEach((element) => { const key = element.getAttribute('data-i18n-title'); element.title = resolve(key); }); } /** * Get current locale * @returns {string} - The current locale code */ function getCurrentLocale() { return currentLocale; } /** * Get list of supported locales * @returns {Array} - Array of supported locale codes */ function getSupportedLocales() { return [...supportedLocales]; } // Flag to track if translations are loaded let translationsLoaded = false; // Initialize when DOM is ready document.addEventListener('DOMContentLoaded', async () => { await initI18n(); // translationsLoaded already set inside initI18n // Dispatch an event when translations are fully loaded window.dispatchEvent(new Event('translationsLoaded')); }); // Self-contained t wrapper — does NOT call the global t() because other // scripts (e.g. admin.js) may shadow it, which would cause infinite recursion. function safeT(key, params = {}) { const localeData = translations[currentLocale]; if (!localeData) { // Translations not loaded yet — return humanised key suffix return key.split('.').pop() || key; } let value = getNestedValue(localeData, key); // Fallback to English if (!value && currentLocale !== 'en' && translations.en) { value = getNestedValue(translations.en, key); } if (!value) return key; return interpolate(value, params); } // Export functions for use in other modules window.i18n = { t: safeT, setLocale, getCurrentLocale, getSupportedLocales, translatePage, translateElement, isLoaded: () => translationsLoaded };