Files
BetterDesk/web-nodejs/services/i18nService.js
T
UNITRONIX c5970657e7 feat(i18n): expose all panel languages in selector
Render language choices dynamically from loaded locale files instead of hardcoding EN/PL. Add selectors on the navbar, login screen, and desktop login screen so users can choose a language before or after authentication.

Persist the selected language in the browser via cookie/localStorage and on authenticated accounts via users.preferred_language. Merge client translations with the default locale fallback so incomplete language files do not surface raw keys in the UI.
2026-05-03 02:02:15 +02:00

349 lines
12 KiB
JavaScript

/**
* BetterDesk Console - i18n Service
* Translation manager with JSON-based language files
*/
const fs = require('fs');
const path = require('path');
const config = require('../config/config');
// Supported languages metadata
const LANGUAGE_META = {
'en': { name: 'English', native: 'English', flag: '🇬🇧', rtl: false },
'pl': { name: 'Polish', native: 'Polski', flag: '🇵🇱', rtl: false },
'de': { name: 'German', native: 'Deutsch', flag: '🇩🇪', rtl: false },
'fr': { name: 'French', native: 'Français', flag: '🇫🇷', rtl: false },
'es': { name: 'Spanish', native: 'Español', flag: '🇪🇸', rtl: false },
'it': { name: 'Italian', native: 'Italiano', flag: '🇮🇹', rtl: false },
'pt': { name: 'Portuguese', native: 'Português', flag: '🇵🇹', rtl: false },
'nl': { name: 'Dutch', native: 'Nederlands', flag: '🇳🇱', rtl: false },
'zh': { name: 'Chinese', native: '中文', flag: '🇨🇳', rtl: false },
'ja': { name: 'Japanese', native: '日本語', flag: '🇯🇵', rtl: false },
'ko': { name: 'Korean', native: '한국어', flag: '🇰🇷', rtl: false },
'ar': { name: 'Arabic', native: 'العربية', flag: '🇸🇦', rtl: true },
'he': { name: 'Hebrew', native: 'עברית', flag: '🇮🇱', rtl: true },
'uk': { name: 'Ukrainian', native: 'Українська', flag: '🇺🇦', rtl: false },
'tr': { name: 'Turkish', native: 'Türkçe', flag: '🇹🇷', rtl: false },
'hi': { name: 'Hindi', native: 'हिन्दी', flag: '🇮🇳', rtl: false },
'sv': { name: 'Swedish', native: 'Svenska', flag: '🇸🇪', rtl: false },
'nb': { name: 'Norwegian', native: 'Norsk', flag: '🇳🇴', rtl: false },
'da': { name: 'Danish', native: 'Dansk', flag: '🇩🇰', rtl: false },
'fi': { name: 'Finnish', native: 'Suomi', flag: '🇫🇮', rtl: false },
'cs': { name: 'Czech', native: 'Čeština', flag: '🇨🇿', rtl: false },
'hu': { name: 'Hungarian', native: 'Magyar', flag: '🇭🇺', rtl: false },
'ro': { name: 'Romanian', native: 'Română', flag: '🇷🇴', rtl: false },
'th': { name: 'Thai', native: 'ไทย', flag: '🇹🇭', rtl: false },
'vi': { name: 'Vietnamese', native: 'Tiếng Việt', flag: '🇻🇳', rtl: false },
'id': { name: 'Indonesian', native: 'Bahasa Indonesia', flag: '🇮🇩', rtl: false },
'zh-TW': { name: 'Chinese (Traditional)', native: '繁體中文', flag: '🇹🇼', rtl: false }
};
// Security: Language code validation regex (prevents path traversal)
// Supports ISO 639-1 codes (e.g. "en") and BCP 47 tags (e.g. "zh-TW")
const VALID_LANG_CODE = /^[a-z]{2,8}(-[A-Z][a-zA-Z]{1,7})?$/;
/**
* Validate language code format to prevent path traversal attacks.
* @param {string} code - Language code to validate
* @returns {boolean} - True if valid
*/
function isValidLangCode(code) {
return typeof code === 'string' && VALID_LANG_CODE.test(code);
}
function stripBom(content) {
return typeof content === 'string' && content.charCodeAt(0) === 0xFEFF
? content.slice(1)
: content;
}
function deepMerge(base, overlay) {
if (!base || typeof base !== 'object') return overlay;
if (!overlay || typeof overlay !== 'object') return base;
const merged = Array.isArray(base) ? [...base] : { ...base };
for (const [key, value] of Object.entries(overlay)) {
if (value && typeof value === 'object' && !Array.isArray(value) && base[key] && typeof base[key] === 'object' && !Array.isArray(base[key])) {
merged[key] = deepMerge(base[key], value);
} else {
merged[key] = value;
}
}
return merged;
}
class TranslationManager {
constructor() {
this.translations = {};
this.availableLanguages = {};
this.defaultLang = config.defaultLanguage || 'en';
}
/**
* Initialize - load all language files
*/
init() {
this.loadAll();
console.log(`i18n: Loaded ${Object.keys(this.availableLanguages).length} languages`);
}
/**
* Load all language files from lang directory
*/
loadAll() {
const langDir = config.langDir;
if (!fs.existsSync(langDir)) {
console.warn(`Language directory not found: ${langDir}`);
return;
}
const files = fs.readdirSync(langDir).filter(f => f.endsWith('.json'));
for (const file of files) {
const code = path.basename(file, '.json');
this.loadLanguage(code);
}
}
/**
* Load a single language file
*/
loadLanguage(code) {
// Security: Validate language code to prevent path traversal
if (!isValidLangCode(code)) {
console.warn(`i18n: Invalid language code rejected: ${code}`);
return false;
}
const filePath = path.join(config.langDir, `${code}.json`);
try {
if (!fs.existsSync(filePath)) {
return false;
}
const content = stripBom(fs.readFileSync(filePath, 'utf8'));
const data = JSON.parse(content);
this.translations[code] = data;
// Get metadata from file or fallback
const meta = data._meta || LANGUAGE_META[code] || {
name: code.toUpperCase(),
native: code.toUpperCase(),
flag: '🌐',
rtl: false
};
this.availableLanguages[code] = {
code,
name: meta.name,
native: meta.native,
flag: meta.flag,
rtl: meta.rtl || false
};
return true;
} catch (err) {
console.warn(`Failed to load language ${code}:`, err.message);
return false;
}
}
/**
* Get a translation value by dot-separated key
*/
translate(lang, key, vars = {}) {
// Try requested language
let value = this._getNestedValue(this.translations[lang], key);
// Fallback to default language
if (value === undefined && lang !== this.defaultLang) {
value = this._getNestedValue(this.translations[this.defaultLang], key);
}
// Fallback to key itself
if (value === undefined) {
return key;
}
// Interpolate variables {varName}
if (typeof value === 'string' && Object.keys(vars).length > 0) {
for (const [varName, varValue] of Object.entries(vars)) {
value = value.replace(new RegExp(`\\{${varName}\\}`, 'g'), varValue);
}
}
return value;
}
/**
* Get nested value from object using dot notation
*/
_getNestedValue(obj, key) {
if (!obj || !key) return undefined;
const parts = key.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
current = current[part];
}
return current;
}
/**
* Get all available languages
*/
getAvailable() {
return this.availableLanguages;
}
/**
* Get available languages as a sorted list for UI rendering.
*/
getAvailableList() {
return Object.values(this.availableLanguages)
.sort((a, b) => (a.native || a.name || a.code).localeCompare(b.native || b.name || b.code, undefined, { sensitivity: 'base' }));
}
/**
* Get all translations for a language
*/
getTranslations(code) {
return this.translations[code] || null;
}
/**
* Get translations with default-language fallback merged in.
* Useful for client-side i18n where missing keys should not surface as raw keys.
*/
getMergedTranslations(code) {
const requested = this.translations[code];
const fallback = this.translations[this.defaultLang] || {};
if (!requested) return fallback;
if (code === this.defaultLang) return requested;
return deepMerge(fallback, requested);
}
/**
* Check if language exists
*/
hasLanguage(code) {
return code in this.translations;
}
/**
* Get language metadata
*/
getLanguageMeta(code) {
return this.availableLanguages[code] || null;
}
/**
* Validate language file (compare keys with default language)
*/
validateLanguage(code) {
if (!this.translations[code]) {
return { valid: false, error: 'Language not found' };
}
if (code === this.defaultLang) {
return { valid: true, missing: [], extra: [] };
}
const defaultKeys = this._getAllKeys(this.translations[this.defaultLang]);
const langKeys = this._getAllKeys(this.translations[code]);
const missing = defaultKeys.filter(k => !langKeys.includes(k));
const extra = langKeys.filter(k => !defaultKeys.includes(k) && !k.startsWith('_'));
return {
valid: missing.length === 0,
missing,
extra,
coverage: ((defaultKeys.length - missing.length) / defaultKeys.length * 100).toFixed(1)
};
}
/**
* Get all keys from an object (flattened)
*/
_getAllKeys(obj, prefix = '') {
const keys = [];
for (const [key, value] of Object.entries(obj || {})) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
keys.push(...this._getAllKeys(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys;
}
/**
* Add or update a language
*/
saveLanguage(code, translations) {
// Security: Validate language code to prevent path traversal
if (!isValidLangCode(code)) {
return { success: false, error: 'Invalid language code format' };
}
const filePath = path.join(config.langDir, `${code}.json`);
try {
fs.writeFileSync(filePath, JSON.stringify(translations, null, 2), 'utf8');
this.loadLanguage(code);
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
}
/**
* Delete a language file
*/
deleteLanguage(code) {
// Security: Validate language code to prevent path traversal
if (!isValidLangCode(code)) {
return { success: false, error: 'Invalid language code format' };
}
// Don't allow deleting default or core languages
if (code === 'en' || code === 'pl') {
return { success: false, error: 'Cannot delete core language' };
}
const filePath = path.join(config.langDir, `${code}.json`);
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
delete this.translations[code];
delete this.availableLanguages[code];
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
}
}
// Singleton instance
const manager = new TranslationManager();
module.exports = {
manager,
LANGUAGE_META,
isValidLangCode
};