i18ntk
Version:
i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.
578 lines (512 loc) • 24 kB
JavaScript
/**
* UI Internationalization Module
* Handles loading and managing UI translations for the i18n management tool
*/
const fs = require('fs');
const path = require('path');
const SettingsManager = require('../settings/settings-manager');
const SecurityUtils = require('../utils/security');
const legacyConfigManager = require('../utils/config-manager');
const { getIcon, isUnicodeSupported } = require('../utils/terminal-icons');
const { formatLanguagePrompt } = require('../utils/language-menu');
const configManager = new SettingsManager();
function getSupportedUiLanguages() {
return configManager.getAvailableLanguages();
}
function getSupportedUiLanguageCodes() {
return getSupportedUiLanguages().map(language => language.code);
}
function getSupportedUiLanguageName(langCode) {
const language = getSupportedUiLanguages().find(item => item.code === langCode);
return language ? language.name : langCode;
}
class UIi18n {
constructor() {
this.currentLanguage = 'en';
this.translations = {};
this.uiLocalesDir = this.resolveUiLocalesDir();
this.availableLanguages = [];
this.configFile = path.resolve(configManager.configFile);
// Initialize with safe defaults
this.initialize();
}
initialize() {
try {
const config = configManager.loadSettings ? configManager.loadSettings() : configManager.getConfig ? configManager.getConfig() : {};
// Use safe defaults if config is not available
this.uiLocalesDir = this.resolveUiLocalesDir();
this.availableLanguages = this.detectAvailableLanguages();
const configuredLanguage = config?.language || config?.uiLanguage || 'en';
// Load language from settings manager or fallback
if (configuredLanguage && this.availableLanguages.includes(configuredLanguage)) {
this.loadLanguage(configuredLanguage);
} else {
this.loadLanguage('en');
}
} catch (error) {
console.warn('UIi18n: Failed to initialize with config, using defaults:', error.message);
this.uiLocalesDir = this.resolveUiLocalesDir();
this.availableLanguages = this.detectAvailableLanguages();
this.loadLanguage('en');
}
}
getValidationBase(targetPath) {
const fallbackBase = path.resolve(__dirname, '..');
if (!targetPath || typeof targetPath !== 'string') {
return fallbackBase;
}
let current = path.resolve(path.dirname(targetPath));
while (true) {
try {
if (fs.statSync(current).isDirectory()) {
return current;
}
} catch (_) {
// Continue walking up until an existing directory is found.
}
const parent = path.dirname(current);
if (parent === current) {
break;
}
current = parent;
}
return fallbackBase;
}
resolveUiLocalesDir() {
const candidates = [
path.resolve(__dirname, '..', 'ui-locales')
];
for (const candidate of candidates) {
try {
const stats = SecurityUtils.safeStatSync(candidate, this.getValidationBase(candidate));
if (stats && stats.isDirectory()) {
return candidate;
}
} catch (_) {
// Try next candidate.
}
}
return candidates[0];
}
/**
/**
* Detect which UI locales are currently installed
* @returns {string[]} Array of available language codes
*/
detectAvailableLanguages() {
const all = getSupportedUiLanguageCodes();
return all.filter(lang => {
const filePath = path.join(this.uiLocalesDir, `${lang}.json`);
return SecurityUtils.safeExistsSync(filePath, this.getValidationBase(filePath));
});
}
/**
* Refresh the list of available languages and ensure current language is valid
*/
refreshAvailableLanguages() {
this.availableLanguages = this.detectAvailableLanguages();
if (!this.availableLanguages.includes(this.currentLanguage)) {
this.loadLanguage('en');
}
}
/**
* Load translations for a specific language
* @param {string} language - Language code (e.g., 'en', 'de', 'es')
*/
loadLanguage(language) {
if (!this.availableLanguages.includes(language)) {
console.warn(`⚠️ Language '${language}' not available. Using English as fallback.`);
language = 'en';
}
this.translations = {}; // Reset translations for the new language
const settings = configManager.loadSettings ? configManager.loadSettings() : configManager.getConfig ? configManager.getConfig() : {};
const debugEnabled = settings.debug?.enabled || false;
if (debugEnabled) {
console.log(`UI: Attempting to load translations from monolith file for: ${language}`);
}
try {
// Primary: Use monolith JSON file (en.json, de.json, etc.)
const monolithTranslationFile = path.join(this.uiLocalesDir, `${language}.json`);
if (SecurityUtils.safeExistsSync(monolithTranslationFile, this.getValidationBase(monolithTranslationFile))) {
try {
const content = SecurityUtils.safeReadFileSync(monolithTranslationFile, path.dirname(monolithTranslationFile), 'utf8');
const fullTranslations = JSON.parse(content);
// Flatten the nested structure for easier key access
this.translations = this.flattenTranslations(fullTranslations);
this.currentLanguage = language;
if (debugEnabled) {
console.log(`UI: Loaded monolith translation file: ${monolithTranslationFile}`);
}
} catch (error) {
console.error(`UI: Error parsing monolith translation file ${monolithTranslationFile}: ${error.message}`);
this.translations = {};
}
} else {
// Fallback: Use folder-based structure if monolith file doesn't exist
const langDir = path.join(this.uiLocalesDir, language);
const langDirStats = SecurityUtils.safeStatSync(langDir, this.getValidationBase(langDir));
if (langDirStats && langDirStats.isDirectory()) {
const files = fs.readdirSync(langDir).filter(file => file.endsWith('.json'));
if (debugEnabled) {
console.log(`UI: Found files in ${langDir}: ${files.join(', ')}`);
}
for (const file of files) {
const filePath = path.join(langDir, file);
try {
const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
const fileTranslations = JSON.parse(content);
const moduleName = path.basename(file, '.json');
this.translations[moduleName] = this.deepMerge(this.translations[moduleName] || {}, fileTranslations);
if (debugEnabled) {
console.log(`UI: Loaded ${filePath}, module: ${moduleName}`);
}
} catch (parseError) {
console.error(`UI: Error parsing translation file ${filePath}: ${parseError.message}`);
}
}
this.currentLanguage = language;
} else {
console.warn(`⚠️ Translation file or directory not found for language ${language}: ${monolithTranslationFile} or ${langDir}`);
if (language !== 'en') {
this.loadLanguage('en'); // Fallback to English
}
}
}
} catch (error) {
console.error(`Error loading translation file for language ${language}: ${error.message}`);
if (language !== 'en') {
this.loadLanguage('en'); // Fallback to English
}
}
}
/**
* Flatten nested translation objects into a single-level object
* @param {object} translations - Nested translation object
* @param {string} prefix - Prefix for nested keys
* @returns {object} Flattened translation object
*/
flattenTranslations(translations, prefix = '') {
const flattened = {};
for (const key in translations) {
if (translations.hasOwnProperty(key)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof translations[key] === 'object' && translations[key] !== null && !Array.isArray(translations[key])) {
// Recursively flatten nested objects
Object.assign(flattened, this.flattenTranslations(translations[key], newKey));
} else {
// Add leaf values directly
flattened[newKey] = translations[key];
}
}
}
return flattened;
}
/**
* Deep merge function for objects
* @param {object} target
* @param {object} source
* @returns {object}
*/
deepMerge(target, source) {
for (const key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key]) && typeof target[key] === 'object' && target[key] !== null && !Array.isArray(target[key])) {
target[key] = this.deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
}
return target;
}
/**
* Get current language from settings manager
* @returns {string} Current language code
*/
getCurrentLanguageFromSettings() {
const settings = configManager.loadSettings ? configManager.loadSettings() : configManager.getConfig ? configManager.getConfig() : {};
return settings.language || settings.uiLanguage || 'en';
}
/**
* Save language preference to settings manager
* @param {string} language - Language code to save
*/
async saveLanguagePreference(language) {
try {
// Use setSetting method which properly persists individual settings
if (configManager.setSetting) {
await configManager.setSetting('language', language);
await configManager.setSetting('uiLanguage', language);
} else {
// Fallback to manual update for backward compatibility
const settings = configManager.loadSettings ? configManager.loadSettings() : configManager.getConfig ? configManager.getConfig() : {};
settings.language = language;
settings.uiLanguage = language;
if (configManager.saveSettings) {
await configManager.saveSettings(settings);
} else if (configManager.updateConfig) {
await configManager.updateConfig(settings);
}
}
// Keep legacy config-manager in sync for modules using it directly
if (legacyConfigManager && legacyConfigManager.updateConfig) {
await legacyConfigManager.updateConfig({ language, uiLanguage: language });
}
} catch (error) {
console.error(`Error saving language preference: ${error.message}`);
}
}
/**
* Refresh language from settings manager
*/
refreshLanguageFromSettings() {
const configuredLanguage = this.getCurrentLanguageFromSettings();
if (configuredLanguage && this.availableLanguages.includes(configuredLanguage)) {
if (configuredLanguage !== this.currentLanguage) {
this.loadLanguage(configuredLanguage);
// Update current language reference
this.currentLanguage = configuredLanguage;
// Force reload translations in i18n-helper
const { loadTranslations } = require('../utils/i18n-helper');
loadTranslations(configuredLanguage);
}
}
}
/**
* Initialize language from settings on startup
* @returns {Promise<string>} Current language code
*/
async initializeLanguage() {
const currentLanguage = this.getCurrentLanguageFromSettings();
// Always load the saved language preference on startup
if (currentLanguage && this.availableLanguages.includes(currentLanguage)) {
await this.changeLanguage(currentLanguage);
return currentLanguage;
} else {
// Fallback to English if saved language is invalid
await this.changeLanguage('en');
return 'en';
}
}
/**
* Get translated text by key path
* @param {string} keyPath - Dot-separated key path (e.g., 'menu.title')
* @param {object} replacements - Object with replacement values
/**
* Process translated text to handle terminal encoding issues
* @param {string} text - Text to process
* @returns {string} Processed text with terminal-safe characters
*/
processTerminalText(text) {
if (typeof text !== 'string' || isUnicodeSupported()) {
return text;
}
// Define emoji mappings for common emojis used in the UI
const emojiMappings = {
'🔧': getIcon('wrench'),
'✅': getIcon('checkmark'),
'❌': getIcon('cross'),
'🚀': getIcon('rocket'),
'📍': getIcon('search'),
'⚙️': getIcon('gear'),
'🔍': getIcon('search'),
'📊': getIcon('analyze'),
'🧹': getIcon('clean'),
'🎉': getIcon('complete'),
'⚠️': getIcon('warning'),
'ℹ️': getIcon('info'),
'📄': getIcon('file'),
'📁': getIcon('folder'),
'💾': getIcon('backup'),
'🌍': getIcon('info'),
'🌐': getIcon('info'),
'🔤': getIcon('info'),
'🗂️': getIcon('folder'),
'🅰️': getIcon('info'),
'🐍': getIcon('python'),
'☕': getIcon('java'),
'🐘': getIcon('php'),
'🐹': getIcon('go'),
'⚛️': getIcon('react'),
'💚': getIcon('vue'),
'🟨': getIcon('javascript'),
'•': getIcon('bullet'),
'→': getIcon('arrow'),
'═': getIcon('separator'),
'╔': getIcon('corner'),
'║': getIcon('line'),
'╚': getIcon('end'),
'✓': getIcon('checkmark')
};
// Replace emojis with terminal-safe alternatives
let processedText = text;
for (const [emoji, replacement] of Object.entries(emojiMappings)) {
processedText = processedText.replace(new RegExp(emoji.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replacement);
}
return processedText;
}
*// @returns {string|array|object} Translated text or data */
t(keyPath, replacements = {}) {
let value = this.translations[keyPath];
if (value === undefined) {
// Try to get English fallback if current language is not English
if (this.currentLanguage !== 'en') {
const englishFallback = this.getEnglishFallback(keyPath, replacements);
if (englishFallback) {
return englishFallback;
}
}
console.warn(`⚠️ Translation key not found: ${keyPath}`);
return keyPath; // Return the key path as fallback
}
// Handle different data types
if (typeof value === 'string') {
// Check if the value is [NOT TRANSLATED] and provide English fallback
if (value === '[NOT TRANSLATED]' && this.currentLanguage !== 'en') {
const englishFallback = this.getEnglishFallback(keyPath, replacements);
if (englishFallback) {
return `${englishFallback} [NOT TRANSLATED]`;
}
return `[NOT TRANSLATED: ${keyPath}]`;
}
// Replace placeholders in strings
let result = value;
for (const [placeholder, replacement] of Object.entries(replacements)) {
result = result.replace(new RegExp(`\\{${placeholder}\\}`, 'g'), replacement);
}
return result;
} else if (Array.isArray(value)) {
// Return arrays as-is (for things like exampleList)
return value;
} else if (typeof value === 'object') {
// Return objects as-is
return value;
} else {
console.warn(`⚠️ Translation value has unexpected type: ${keyPath}`);
return value;
}
}
/**
* Get English fallback for a translation key
* @param {string} keyPath - Dot-separated key path
* @param {object} replacements - Object with replacement values
* @returns {string|null} English translation or null if not found
*/
getEnglishFallback(keyPath, replacements = {}) {
try {
const englishFile = path.join(this.uiLocalesDir, 'en.json');
if (SecurityUtils.safeExistsSync(englishFile, this.getValidationBase(englishFile))) {
const englishContent = SecurityUtils.safeReadFileSync(englishFile, path.dirname(englishFile), 'utf8');
const englishTranslations = JSON.parse(englishContent);
// Use the same flattening approach for consistency
const flattenedEnglish = this.flattenTranslations(englishTranslations);
if (keyPath in flattenedEnglish && typeof flattenedEnglish[keyPath] === 'string') {
let result = flattenedEnglish[keyPath];
for (const [placeholder, replacement] of Object.entries(replacements)) {
result = result.replace(new RegExp(`\\{${placeholder}\\}`, 'g'), replacement);
}
return result;
}
}
} catch (error) {
// Silently fail and return null
}
return null;
}
/**
* Get current language code
* @returns {string} Current language code
*/
getCurrentLanguage() {
return this.currentLanguage;
}
/**
* Get list of available languages
* @returns {Array} Array of language codes
*/
getAvailableLanguages() {
return this.availableLanguages;
}
/**
* Change the current UI language
* @param {string} language - New language code
*/
async changeLanguage(language) {
this.loadLanguage(language);
await this.saveLanguagePreference(language);
}
/**
* Get language display name
* @param {string} langCode - Language code
* @returns {string} Display name of the language
*/
getLanguageDisplayName(langCode) {
// Hardcoded texts that are not part of the i18n system but need to be displayed
this.hardcodedTexts = {
autoDetectedI18nDirectory: this.t('ui.autoDetectedI18nDirectory'),
executingCommand: this.t('ui.executingCommand'),
unknownCommand: this.t('ui.unknownCommand'),
errorExecutingCommand: this.t('ui.errorExecutingCommand')
};
return getSupportedUiLanguageName(langCode);
}
/**
* Interactive language selection menu
* @returns {Promise<string>} Selected language code
*/
async selectLanguage() {
// Use the existing readline interface from the manager if available
const rl = global.activeReadlineInterface;
if (!rl) {
console.error(this.t('ui.noActiveReadlineInterface'));
return this.currentLanguage;
}
return new Promise((resolve) => {
console.log('\n' + this.t('language.title'));
console.log(this.t('language.separator'));
console.log(this.t('language.current', { language: this.getLanguageDisplayName(this.currentLanguage) }));
console.log('\n' + this.t('language.available'));
this.availableLanguages.forEach((lang, index) => {
const displayName = this.getLanguageDisplayName(lang);
const current = lang === this.currentLanguage ? ' ✓' : '';
console.log(this.t('language.languageOption', { index: index + 1, displayName, current }));
});
const languagePrompt = formatLanguagePrompt(this.t('language.prompt'), this.availableLanguages.length);
rl.question('\n' + languagePrompt, async (answer) => {
const choice = parseInt(answer);
if (choice === 0) {
console.log(this.t('language.cancelled'));
resolve(this.currentLanguage);
} else if (choice >= 1 && choice <= this.availableLanguages.length) {
const selectedLang = this.availableLanguages[choice - 1];
await this.changeLanguage(selectedLang);
console.log(this.t('language.changed', { language: this.getLanguageDisplayName(selectedLang) }));
resolve(selectedLang);
} else {
console.log(this.t('language.invalid'));
resolve(this.currentLanguage);
}
});
});
}
/**
* Refresh language from current settings
* Call this after settings changes to update UI language
*/
refreshLanguageFromSettings() {
const settings = configManager.loadSettings ? configManager.loadSettings() : (configManager.getConfig ? configManager.getConfig() : {});
const configuredLanguage = settings.language || settings.uiLanguage || 'en';
if (configuredLanguage && this.availableLanguages.includes(configuredLanguage)) {
if (configuredLanguage !== this.currentLanguage) {
this.loadLanguage(configuredLanguage);
this.currentLanguage = configuredLanguage;
// Force reload translations in i18n-helper
const { loadTranslations } = require('../utils/i18n-helper');
loadTranslations(configuredLanguage);
console.log(this.t('ui.uiLanguageUpdated', { language: this.getLanguageDisplayName(configuredLanguage) }));
}
}
}
}
// Export the class, not a singleton instance
module.exports = UIi18n;