i18ntk
Version:
i18ntk (i18n Toolkit) - A comprehensive, enterprise-grade internationalization (i18n) management toolkit for JavaScript/TypeScript projects with advanced analysis, validation, and automation features
533 lines (447 loc) • 17.6 kB
JavaScript
/**
* I18N TRANSLATION COMPLETION SCRIPT
*
* This script automatically adds missing translation keys to achieve 100% coverage.
* It reads the usage analysis and adds all missing keys with proper markers.
*
* Usage:
* node i18ntk-complete.js
* node i18ntk-complete.js --auto-translate
* node i18ntk-complete.js --source-dir=./src/i18n/locales
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const SecurityUtils = require('../utils/security');
const settingsManager = require('../settings/settings-manager');
// Get configuration from settings manager securely
async function getConfig() {
try {
const settings = await settingsManager.getSettings();
// Check for per-script directory override, fallback to global sourceDir
const sourceDir = settings.scriptDirectories?.complete || settings.sourceDir || './ui-locales';
const config = {
sourceDir: sourceDir,
sourceLanguage: settings.sourceLanguage || 'en',
notTranslatedMarker: settings.notTranslatedMarker || settings.processing?.notTranslatedMarker || 'NOT_TRANSLATED',
excludeFiles: settings.excludeFiles || settings.processing?.excludeFiles || ['.DS_Store', 'Thumbs.db']
};
// Basic validation for required fields
if (!config.sourceDir || !config.sourceLanguage) {
throw new Error(this.t('complete.configurationValidationFailed') || 'Configuration validation failed: missing required fields');
}
return config;
} catch (error) {
throw error;
}
}
// Common missing keys that should be added to all projects
const COMMON_MISSING_KEYS = {
// Offline/Network
'offlineTitle': 'You are offline',
'offlineMessage': 'Please check your internet connection',
'tryReconnect': 'Try to reconnect',
// Common UI
'common': 'Common',
'logout': 'Logout',
'login': 'Login',
'amount': 'Amount',
};
class I18nCompletionTool {
constructor(config = {}) {
this.config = config;
this.sourceDir = null;
this.sourceLanguageDir = null;
this.rl = null;
// Initialize UI i18n for console messages
const UIi18n = require('./i18ntk-ui');
this.ui = new UIi18n();
this.t = this.ui.t.bind(this.ui);
}
async initialize() {
try {
const baseConfig = await getConfig();
this.config = { ...baseConfig, ...this.config };
this.sourceDir = path.resolve(this.config.sourceDir);
this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage);
SecurityUtils.logSecurityEvent(this.t('complete.configLoadedSuccessfully'), 'info');
} catch (error) {
SecurityUtils.logSecurityEvent(this.t('complete.configLoadingFailed'), 'error', { error: error.message });
throw error;
}
}
// Initialize readline interface
initReadline() {
if (!this.rl) {
const readline = require('readline');
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
}
return this.rl;
}
// Prompt for user input
async prompt(question) {
const rl = this.rl || this.initReadline();
return new Promise((resolve) => {
rl.question(question, resolve);
});
}
// Close readline interface
closeReadline() {
if (this.rl) {
this.rl.close();
this.rl = null;
}
}
// Parse command line arguments
parseArgs() {
const args = process.argv.slice(2);
const parsed = {};
args.forEach(arg => {
if (arg.startsWith('--')) {
const [key, value] = arg.substring(2).split('=');
if (key === 'source-dir') {
parsed.sourceDir = value;
} else if (key === 'auto-translate') {
parsed.autoTranslate = true;
} else if (key === 'dry-run') {
parsed.dryRun = true;
} else if (key === 'no-prompt') {
parsed.noPrompt = true;
}
}
});
return parsed;
}
// Get all available languages
getAvailableLanguages() {
if (!fs.existsSync(this.sourceDir)) {
throw new Error(this.t('validate.sourceLanguageDirectoryNotFound', { sourceDir: this.sourceDir }) || `Source directory not found: ${this.sourceDir}`);
}
// Check for monolith JSON files (en.json, es.json, etc.)
const files = fs.readdirSync(this.sourceDir);
const languages = files
.filter(file => file.endsWith('.json'))
.map(file => path.basename(file, '.json'));
// Also check for directory-based structure for backward compatibility
const directories = fs.readdirSync(this.sourceDir)
.filter(item => {
const itemPath = path.join(this.sourceDir, item);
return fs.statSync(itemPath).isDirectory();
});
return [...new Set([...languages, ...directories])];
}
// Get all JSON files from a language directory
getLanguageFiles(language) {
const languageDir = path.join(this.sourceDir, language);
if (!fs.existsSync(languageDir)) {
return [];
}
return fs.readdirSync(languageDir)
.filter(file => {
return file.endsWith('.json') &&
!this.config.excludeFiles.includes(file);
});
}
// Parse key path and determine which file it belongs to
parseKeyPath(keyPath) {
// Handle namespace:key format (e.g., "reportGenerator:reportTypes.prospects")
if (keyPath.includes(':')) {
const [namespace, key] = keyPath.split(':', 2);
return {
file: `${namespace}.json`,
key: key
};
}
// Handle dot notation (e.g., "pagination.showing")
const parts = keyPath.split('.');
if (parts.length > 1) {
return {
file: `${parts[0]}.json`,
key: parts.slice(1).join('.')
};
}
// Default to common.json for simple keys
return {
file: 'common.json',
key: keyPath
};
}
// Check if nested key exists in object
hasNestedKey(obj, keyPath) {
const keys = keyPath.split('.');
let current = obj;
for (const key of keys) {
if (!current || typeof current !== 'object' || !(key in current)) {
return false;
}
current = current[key];
}
return true;
}
// Set nested value in object
setNestedValue(obj, keyPath, value) {
const keys = keyPath.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
}
// Add missing keys to a language
addMissingKeysToLanguage(language, missingKeys, dryRun = false) {
const languageDir = path.join(this.sourceDir, language);
const changes = [];
// Group keys by file
const keysByFile = {};
missingKeys.forEach(keyPath => {
const { file, key } = this.parseKeyPath(keyPath);
if (!keysByFile[file]) {
keysByFile[file] = [];
}
keysByFile[file].push({ keyPath, key });
});
// Process each file
for (const [fileName, keys] of Object.entries(keysByFile)) {
const filePath = path.join(languageDir, fileName);
let fileContent = {};
// Load existing file or create new
if (fs.existsSync(filePath)) {
try {
fileContent = JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
console.warn(this.t("completeTranslations.warning_could_not_parse_filepa", { filePath })); ;
fileContent = {};
}
} else {
// Create directory if it doesn't exist
if (!fs.existsSync(languageDir)) {
if (!dryRun) {
fs.mkdirSync(languageDir, { recursive: true });
}
}
}
// Add missing keys
let fileChanged = false;
keys.forEach(({ keyPath, key }) => {
// Check if key already exists
if (!this.hasNestedKey(fileContent, key)) {
const value = this.generateTranslationValue(keyPath, language);
this.setNestedValue(fileContent, key, value);
fileChanged = true;
changes.push({
file: fileName,
key: keyPath,
value,
action: 'added'
});
}
});
// Save file
if (fileChanged && !dryRun) {
fs.writeFileSync(filePath, JSON.stringify(fileContent, null, 2), 'utf8');
}
}
return changes;
}
// Generate appropriate translation value based on key and language
generateTranslationValue(keyPath, language) {
// Use English as base value from COMMON_MISSING_KEYS or generate from key
const baseValue = COMMON_MISSING_KEYS[keyPath] || this.generateValueFromKey(keyPath);
// For source language, use the base value (never use namespace prefix)
if (language === this.config.sourceLanguage) {
return baseValue;
}
// For other languages, use the not translated marker
return 'NOT_TRANSLATED';
}
// Generate a readable value from a key path
generateValueFromKey(keyPath) {
// Extract the last part of the key (after dots and colons)
const keyName = keyPath.split('.').pop().split(':').pop();
// Convert camelCase to readable text
const readable = keyName
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
// Never return the namespace prefix as the value
return readable || keyName;
}
// Get missing keys from usage analysis
getMissingKeysFromUsage() {
const usageReportPath = path.join(process.cwd(), 'reports', 'usage-analysis.txt');
// Delete old report to ensure fresh data
if (fs.existsSync(usageReportPath)) {
console.log(this.t("operations.complete.deletingOldReport"));
fs.unlinkSync(usageReportPath);
}
// Generate fresh usage analysis report (quietly)
console.log(this.t("operations.complete.generatingFreshAnalysis"));
const { execSync } = require('child_process');
try {
execSync('node main/i18ntk-usage.js --output-report', {
stdio: 'pipe', // Suppress output to avoid double display
cwd: process.cwd()
});
} catch (error) {
console.log(this.t("operations.complete.couldNotGenerate"));
return Object.keys(COMMON_MISSING_KEYS);
}
if (!fs.existsSync(usageReportPath)) {
console.log(this.t("operations.complete.reportNotFound"));
return Object.keys(COMMON_MISSING_KEYS);
}
try {
const reportContent = fs.readFileSync(usageReportPath, 'utf8');
const missingKeys = [];
// Extract missing keys from the report
const lines = reportContent.split('\n');
let inMissingSection = false;
for (const line of lines) {
if (line.includes('MISSING TRANSLATION KEYS')) {
inMissingSection = true;
continue;
}
if (inMissingSection) {
// Stop when we reach another section
if (line.includes('DYNAMIC KEYS') || line.includes('USED KEYS') || line.includes('UNUSED KEYS')) {
break;
}
// Extract key from lines like "⚠️ offlineTitle"
const match = line.match(/^⚠️\s+(.+)$/);
if (match) {
const key = match[1].trim();
// Skip lines that are file paths or other metadata
if (!key.includes('Used in:') && !key.includes('📄') && key.length > 0 && !missingKeys.includes(key)) {
missingKeys.push(key);
}
}
}
}
console.log(this.t("operations.complete.foundMissingKeys", { count: missingKeys.length }));
return missingKeys.length > 0 ? missingKeys : Object.keys(COMMON_MISSING_KEYS);
} catch (error) {
console.log(this.t("operations.complete.couldNotParse"));
return Object.keys(COMMON_MISSING_KEYS);
}
}
// Run the completion process
async run(options = {}) {
const { fromMenu = false } = options;
SecurityUtils.logSecurityEvent('I18n completion tool started', 'info', {
version: '1.3.7',
nodeVersion: process.version,
platform: process.platform
});
const args = this.parseArgs();
// Skip admin authentication when called from menu
if (!fromMenu) {
// Check admin authentication for sensitive operations (only when called directly and not in no-prompt mode)
const AdminAuth = require('../utils/admin-auth');
const adminAuth = new AdminAuth();
await adminAuth.initialize();
const isCalledDirectly = require.main === module;
const isRequired = await adminAuth.isAuthRequired();
if (isRequired && isCalledDirectly && !args.noPrompt) {
console.log('\n' + this.t('adminCli.authRequiredForOperation', { operation: 'complete translations' }));
const pin = await this.prompt(this.t('adminCli.enterPin'));
const isValid = await adminAuth.verifyPin(pin);
if (!isValid) {
console.log(this.t('adminCli.invalidPin'));
if (!fromMenu) process.exit(1);
return { success: false, error: 'Authentication failed' };
}
console.log(this.t('adminCli.authenticationSuccess'));
}
}
await this.initialize();
if (args.sourceDir) {
this.config.sourceDir = args.sourceDir;
this.sourceDir = path.resolve(this.config.sourceDir);
this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage);
}
console.log(this.t("operations.complete.title"));
console.log(this.t("operations.complete.separator"));
console.log(this.t("operations.complete.sourceDir", { sourceDir: this.sourceDir }));
console.log(this.t("operations.complete.sourceLanguage", { sourceLanguage: this.config.sourceLanguage }));
if (args.dryRun) {
console.log(this.t("operations.complete.dryRunMode"));
}
try {
// Get available languages
const languages = this.getAvailableLanguages();
console.log(this.t("operations.complete.languages", { languages: languages.join(', ') }));
// Get missing keys from usage analysis or use common keys
const missingKeys = this.getMissingKeysFromUsage();
console.log(this.t("operations.complete.addingMissingKeys"));
let totalChanges = 0;
// Process each language
for (const language of languages) {
console.log(this.t("operations.complete.processing", { language }));
const changes = this.addMissingKeysToLanguage(language, missingKeys, args.dryRun);
if (changes.length > 0) {
console.log(this.t("operations.complete.addedKeys", { count: changes.length }));
totalChanges += changes.length;
// Show sample of changes
const sampleChanges = changes.slice(0, 3);
sampleChanges.forEach(change => {
console.log(this.t("operations.complete.changeDetails", { file: change.file, key: change.key }));
});
if (changes.length > 3) {
console.log(this.t("complete.andMore", { count: changes.length - 3 }));
}
} else {
console.log(this.t("operations.complete.noChangesNeeded", { language }));
}
}
console.log('\n');
console.log(this.t("operations.complete.summaryTitle"));
console.log(this.t("operations.complete.separator"));
console.log(this.t("operations.complete.totalChanges", { totalChanges }));
console.log(this.t("operations.complete.languagesProcessed", { languagesProcessed: languages.length }));
console.log(this.t("operations.complete.missingKeysAdded", { missingKeysAdded: missingKeys.length }));
if (!args.dryRun) {
console.log('\n' + this.t("operations.complete.nextStepsTitle"));
console.log(this.t("operations.complete.separator"));
console.log(this.t("operations.complete.nextStep1"));
console.log(' node i18ntk-usage.js --output-report');
console.log(this.t("operations.complete.nextStep2"));
console.log(' node i18ntk-validate.js');
console.log(this.t("operations.complete.nextStep3"));
console.log(' node i18ntk-analyze.js');
console.log('\n' + this.t("operations.complete.allKeysAvailable"));
} else {
console.log('\n' + this.t("operations.complete.runWithoutDryRun"));
}
// Only prompt when run from the menu (i.e., when a callback or menu context is present)
if (typeof this.prompt === "function" && args.fromMenu) {
console.log(this.t('operations.completed'));
await this.prompt(this.t('operations.pressEnterToContinue'));
}
} catch (error) {
console.error(this.t('operations.complete.errorDuringCompletion', { error: error.message }));
process.exit(1);
}
}
}
// Run if called directly
if (require.main === module) {
const tool = new I18nCompletionTool();
tool.run().then(() => {
process.exit(0);
}).catch(error => {
const UIi18n = require('./i18ntk-ui');
const ui = new UIi18n();
console.error(this.t('operations.complete.fatalError', { error: error.message }));
SecurityUtils.logSecurityEvent('I18n completion tool failed', 'error', { error: error.message });
process.exit(1);
});
}
module.exports = I18nCompletionTool;