UNPKG

i18ntk

Version:

i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.

725 lines (608 loc) 26.8 kB
#!/usr/bin/env node /** * I18NTK 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 path = require('path'); const fs = require('fs'); const SecurityUtils = require('../utils/security'); const { loadTranslations, t } = require('../utils/i18n-helper'); const { getGlobalReadline, closeGlobalReadline } = require('../utils/cli'); const SetupEnforcer = require('../utils/setup-enforcer'); const { getUnifiedConfig, displayHelp } = require('../utils/config-helper'); const { isInteractive } = require('../utils/prompt-helper'); // Ensure setup is complete before running, except for help output. (async () => { const isHelpRequest = process.argv.slice(2).some(arg => arg === '--help' || arg === '-h'); if (isHelpRequest) { return; } try { await SetupEnforcer.checkSetupCompleteAsync(); } catch (error) { console.error('Setup check failed:', error.message); process.exit(1); } })(); loadTranslations(); 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'); // Using shared t() helper from i18n-helper instead of UIi18n for translations // Using shared t() helper from i18n-helper } async initialize() { try { const args = this.parseArgs(); if (args.help) { displayHelp('i18ntk-complete', { 'auto-translate': 'Enable automatic translation suggestions', 'dry-run': 'Preview changes without applying them' }); process.exit(0); } const baseConfig = await getUnifiedConfig('complete', args); this.config = { ...baseConfig, ...(this.config || {}) }; this.sourceDir = this.config.localesDir || this.config.i18nDir || this.config.sourceDir; this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage); // Validate source directory exists const { validateSourceDir } = require('../utils/config-helper'); validateSourceDir(this.sourceDir, 'i18ntk-complete'); SecurityUtils.logSecurityEvent(t('complete.configLoadedSuccessfully'), 'info'); } catch (error) { SecurityUtils.logSecurityEvent(t('complete.configLoadingFailed'), 'error', { error: error.message }); throw error; } } // Initialize readline interface initReadline() { return getGlobalReadline(); } // Prompt for user input async prompt(question) { const rl = getGlobalReadline(); return new Promise(resolve => rl.question(question, resolve)); } // Close readline interface closeReadline() { closeGlobalReadline(); } // Parse command line arguments parseArgs() { const args = process.argv.slice(2); const parsed = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith('--')) { const [key, rawValue] = arg.substring(2).split('='); const value = rawValue !== undefined ? rawValue : (args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true); if (key === 'source-dir' || key === 'locales-dir' || key === 'i18n-dir') { parsed.sourceDir = value; parsed.i18nDir = value; } else if (key === 'source-language' || key === 'source-locale') { parsed.sourceLanguage = value; } else if (key === 'code-dir' || key === 'source-code-dir') { parsed.codeDir = 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; } else if (key === 'help' || key === 'h') { parsed.help = true; } } } return parsed; } // Get all available languages getAvailableLanguages() { if (!SecurityUtils.safeExistsSync(this.sourceDir, this.config.projectRoot)) { throw new Error(`Source directory not found: ${this.sourceDir}`); } // Check for monolith JSON files (en.json, es.json, etc.) const files = SecurityUtils.safeReaddirSync(this.sourceDir, this.config.projectRoot); const languagesFromFiles = files .filter(file => file.endsWith('.json')) .map(file => path.basename(file, '.json')) .filter(code => this.isValidLanguageCode(code)); // Also check for directory-based structure for backward compatibility const directories = SecurityUtils.safeReaddirSync(this.sourceDir, this.config.projectRoot) .filter(item => { if (this.isExcludedLanguageDirectory(item)) return false; if (!this.isValidLanguageCode(item)) return false; const itemPath = path.join(this.sourceDir, item); const stat = SecurityUtils.safeStatSync(itemPath, this.config.projectRoot); if (!stat || !stat.isDirectory()) return false; const localeFiles = SecurityUtils.safeReaddirSync(itemPath, this.config.projectRoot) .filter(name => name.endsWith('.json')); return localeFiles.length > 0; }); return [...new Set([...languagesFromFiles, ...directories])]; } isValidLanguageCode(code) { if (!code || typeof code !== 'string') return false; return /^[a-z]{2}(?:-[A-Za-z0-9]{2,8})*$/i.test(code.trim()); } isExcludedLanguageDirectory(name) { if (!name || typeof name !== 'string') return true; const lowered = name.toLowerCase(); return lowered.startsWith('backup-') || lowered === 'backup' || lowered === 'reports' || lowered === 'i18ntk-reports'; } // Get all JSON files from a language directory getLanguageFiles(language) { const monolithFile = path.join(this.sourceDir, `${language}.json`); if (SecurityUtils.safeExistsSync(monolithFile, this.config.projectRoot)) { return [`${language}.json`]; } const languageDir = path.join(this.sourceDir, language); if (!SecurityUtils.safeExistsSync(languageDir, this.config.projectRoot)) { return []; } return SecurityUtils.safeReaddirSync(languageDir, this.config.projectRoot) .filter(file => { return file.endsWith('.json') && !this.config.excludeFiles.includes(file); }); } getLanguageFilePath(language, fileName) { if (fileName === `${language}.json`) { return path.join(this.sourceDir, fileName); } return path.join(this.sourceDir, language, fileName); } usesMonolithFile(language) { return SecurityUtils.safeExistsSync(path.join(this.sourceDir, `${language}.json`), this.config.projectRoot); } // 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 keyPathStr = String(keyPath || ''); const parts = keyPathStr.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 keyPathStr = String(keyPath || ''); const keys = keyPathStr.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 keyPathStr = String(keyPath || ''); const keys = keyPathStr.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 = []; const usesMonolith = this.usesMonolithFile(language); // Group keys by file const keysByFile = {}; missingKeys.forEach(keyPath => { const { file, key } = usesMonolith ? { file: `${language}.json`, key: keyPath } : 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 = usesMonolith ? path.join(this.sourceDir, fileName) : path.join(languageDir, fileName); let fileContent = {}; // Load existing file or create new if (SecurityUtils.safeExistsSync(filePath, this.config.projectRoot)) { try { fileContent = JSON.parse(SecurityUtils.safeReadFileSync(filePath, this.config.projectRoot, 'utf8')); } catch (error) { console.warn(t("completeTranslations.warning_could_not_parse_filepa", { filePath })); ; fileContent = {}; } } else { // Create directory if it doesn't exist if (!usesMonolith && !SecurityUtils.safeExistsSync(languageDir, this.config.projectRoot)) { if (!dryRun) { SecurityUtils.safeMkdirSync(languageDir, this.config.projectRoot, { recursive: true }); } } } // Add missing keys let fileChanged = false; // Detect namespace wrapper: if file is "auth.json" and content has an "auth" top-level key, // keys should be inserted inside that wrapper, not at root. const fileNamespace = path.basename(fileName, '.json'); const hasWrapper = fileContent && typeof fileContent === 'object' && !Array.isArray(fileContent) && fileNamespace in fileContent && typeof fileContent[fileNamespace] === 'object'; keys.forEach(({ keyPath, key }) => { // Resolve the correct insertion path: if the file has a namespace wrapper matching // the filename, prepend it so keys go inside the wrapper (e.g. "auth.panel.sign_in" // instead of "panel.sign_in"). const effectiveKey = hasWrapper && !key.startsWith(fileNamespace + '.') ? `${fileNamespace}.${key}` : key; // Check if key already exists at the effective path if (!this.hasNestedKey(fileContent, effectiveKey)) { const value = this.generateTranslationValue(keyPath, language); this.setNestedValue(fileContent, effectiveKey, value); fileChanged = true; changes.push({ file: fileName, key: keyPath, value, action: 'added' }); } }); // Save file if (fileChanged && !dryRun) { SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(fileContent, null, 2), this.config.projectRoot, 'utf8'); } } return changes; } // Generate appropriate translation value based on key and language generateTranslationValue(keyPath, language) { const sourceValue = this.getSourceValueForKeyPath(keyPath); const baseValue = typeof sourceValue === 'string' && sourceValue.trim() !== '' ? sourceValue : this.generateValueFromKey(keyPath); // For source language, use the generated value if (language === this.config.sourceLanguage) { return baseValue; } return `[${language.toUpperCase()}] ${baseValue}`; } getNestedValue(obj, keyPath) { const keys = String(keyPath || '').split('.'); let current = obj; for (const key of keys) { if (!current || typeof current !== 'object' || !(key in current)) { return undefined; } current = current[key]; } return current; } getSourceValueForKeyPath(keyPath) { if (!this.sourceLanguageDir && !this.usesMonolithFile(this.config.sourceLanguage)) { return undefined; } const sourceFiles = this.getLanguageFiles(this.config.sourceLanguage); const keyPathStr = String(keyPath || ''); const parsed = this.parseKeyPath(keyPathStr); for (const fileName of sourceFiles) { const sourceFilePath = this.getLanguageFilePath(this.config.sourceLanguage, fileName); try { const sourceContent = SecurityUtils.safeParseJSON(SecurityUtils.safeReadFileSync(sourceFilePath, this.config.projectRoot, 'utf8')); if (!sourceContent || typeof sourceContent !== 'object') continue; const candidates = [keyPathStr]; if (fileName === parsed.file) { candidates.push(parsed.key); } for (const candidate of candidates) { const value = this.getNestedValue(sourceContent, candidate); if (value !== undefined) return value; } } catch (error) { console.warn(t("complete.couldNotParseSource", { file: sourceFilePath })); } } return undefined; } // Generate a readable value from a key path generateValueFromKey(keyPath) { // Extract the last part of the key (after dots and colons) const keyPathStr = String(keyPath || ''); const keyName = keyPathStr.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 all keys from a nested object with full paths 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; } // Get missing keys by comparing source language with target languages getMissingKeysFromComparison() { const sourceFiles = this.getLanguageFiles(this.config.sourceLanguage); const missingKeys = []; if (sourceFiles.length === 0) { console.log(t("complete.sourceLanguageNotFound", { sourceLanguage: this.config.sourceLanguage })); return []; } // Process each file in source language for (const fileName of sourceFiles) { const sourceFilePath = this.getLanguageFilePath(this.config.sourceLanguage, fileName); try { const sourceContent = SecurityUtils.safeParseJSON(SecurityUtils.safeReadFileSync(sourceFilePath, this.config.projectRoot, 'utf8')); const sourceKeys = this.getAllKeys(sourceContent); // Check all other languages const languages = this.getAvailableLanguages(); for (const language of languages) { if (language === this.config.sourceLanguage) continue; const targetFilePath = fileName === `${this.config.sourceLanguage}.json` || this.usesMonolithFile(language) ? path.join(this.sourceDir, `${language}.json`) : path.join(this.sourceDir, language, fileName); let targetKeys = []; if (SecurityUtils.safeExistsSync(targetFilePath, this.config.projectRoot)) { try { const targetContent = SecurityUtils.safeParseJSON(SecurityUtils.safeReadFileSync(targetFilePath, this.config.projectRoot, 'utf8')); targetKeys = this.getAllKeys(targetContent); } catch (error) { console.warn(t("complete.couldNotParseTarget", { file: targetFilePath })); } } // Find keys missing in target language const missingInTarget = sourceKeys.filter(key => !targetKeys.includes(key)); missingKeys.push(...missingInTarget); } } catch (error) { console.warn(t("complete.couldNotParseSource", { file: sourceFilePath })); } } // Remove duplicates const uniqueMissingKeys = [...new Set(missingKeys)]; console.log(t("complete.foundMissingKeys", { count: uniqueMissingKeys.length })); return uniqueMissingKeys; } // Generate completion report async generateReport(changes, languages) { const projectRoot = this.config.projectRoot || process.cwd(); const reportsDir = path.join(projectRoot, 'i18ntk-reports'); if (!SecurityUtils.safeExistsSync(reportsDir, projectRoot)) { SecurityUtils.safeMkdirSync(reportsDir, projectRoot, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const reportPath = path.join(reportsDir, `completion-report-${timestamp}.json`); const report = { timestamp: new Date().toISOString(), sourceLanguage: this.config.sourceLanguage, sourceDir: this.sourceDir, languagesProcessed: languages.length, totalChanges: changes.reduce((sum, lang) => sum + lang.changes.length, 0), languages: changes.map(lang => ({ language: lang.language, changes: lang.changes.length, files: lang.changes.reduce((acc, change) => { if (!acc[change.file]) acc[change.file] = []; acc[change.file].push({ key: change.key, value: change.value, action: change.action }); return acc; }, {}) })) }; SecurityUtils.safeWriteFileSync(reportPath, JSON.stringify(report, null, 2), projectRoot, 'utf8'); console.log(t("complete.reportGenerated", { path: reportPath })); return reportPath; } // Run the completion process async run(options = {}) { const { fromMenu = false } = options; SecurityUtils.logSecurityEvent('I18n completion tool started', 'info', { version: this.config.version, 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' + t('adminCli.authRequiredForOperation', { operation: 'complete translations' })); const cliHelper = require('../utils/cli-helper'); const pin = await cliHelper.promptPin(t('adminCli.enterPin')); const isValid = await this.adminAuth.verifyPin(pin); if (!isValid) { console.log(t('adminCli.invalidPin')); if (!fromMenu) process.exit(1); return { success: false, error: 'Authentication failed' }; } console.log(t('adminCli.authenticationSuccess')); } } // Initialize configuration properly when called from menu if (fromMenu && !this.sourceDir) { const baseConfig = await getUnifiedConfig('complete', args); this.config = { ...baseConfig, ...(this.config || {}) }; const uiLanguage = (this.config && this.config.uiLanguage) || 'en'; loadTranslations(uiLanguage, path.resolve(__dirname, '..', 'ui-locales')); this.sourceDir = this.config.localesDir || this.config.i18nDir || this.config.sourceDir; this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage); } else { await this.initialize(); const localesDirArg = args.i18nDir || args.sourceDir; if (localesDirArg) { const resolvedSourceDir = path.resolve(localesDirArg); const validatedSourceDir = SecurityUtils.validatePath(resolvedSourceDir, process.cwd()); if (!validatedSourceDir) { console.error(t("complete.invalidSourceDir", { sourceDir: localesDirArg })); console.error(`Path validation failed — source directory is outside the allowed project boundary.`); process.exit(1); } this.config.sourceDir = resolvedSourceDir; this.config.i18nDir = resolvedSourceDir; this.sourceDir = validatedSourceDir; } if (args.sourceLanguage) { this.config.sourceLanguage = SecurityUtils.sanitizeInput(args.sourceLanguage, { allowedChars: /^[a-zA-Z0-9\-_]+$/, maxLength: 10, removeHTML: true }); } this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage); } console.log(t("complete.title")); console.log(t("complete.separator")); console.log(t("complete.sourceDir", { sourceDir: this.sourceDir })); console.log(t("complete.sourceLanguage", { sourceLanguage: this.config.sourceLanguage })); if (args.dryRun) { console.log(t("complete.dryRunMode")); } try { // Get available languages const languages = this.getAvailableLanguages(); console.log(t("complete.languages", { languages: languages.join(', ') })); // Get missing keys by comparing source language with others const missingKeys = this.getMissingKeysFromComparison(); console.log(t("complete.addingMissingKeys")); let totalChanges = 0; // Process all languages except source language const targetLanguages = languages.filter(lang => lang !== this.config.sourceLanguage); const allChanges = []; for (const language of targetLanguages) { console.log(t("complete.processing", { language })); const changes = this.addMissingKeysToLanguage(language, missingKeys, args.dryRun); if (changes.length > 0) { console.log(t("complete.addedKeys", { count: changes.length })); totalChanges += changes.length; allChanges.push({ language, changes }); // Show sample of changes const sampleChanges = changes.slice(0, 3); sampleChanges.forEach(change => { console.log(t("usage.complete.changeDetails", { file: change.file, key: change.key })); }); if (changes.length > 3) { console.log(t("usage.complete.andMore", { count: changes.length - 3 })); } } else { console.log(t("usage.complete.noChangesNeeded", { language })); } } console.log('\n'); console.log(t("complete.summaryTitle")); console.log(t("complete.separator")); const targetLocalesChanged = allChanges.length; const filesModified = new Set(allChanges.flatMap(lang => lang.changes.map(change => `${lang.language}/${change.file}`))).size; const filesSkipped = Math.max(0, targetLanguages.length - targetLocalesChanged); console.log(`Locales scanned: ${languages.length}`); console.log(`Target locales changed: ${targetLocalesChanged}`); console.log(`Unique source keys added: ${missingKeys.length}`); console.log(`Total key insertions: ${totalChanges}`); console.log(`Files modified: ${args.dryRun ? 0 : filesModified}`); console.log(`Files skipped: ${filesSkipped}`); if (args.dryRun) { console.log('Dry run only. No files were modified.'); } if (!args.dryRun && allChanges.length > 0 && isInteractive(args)) { const rl = this.rl || this.initReadline(); const answer = await this.prompt('\n' + t('complete.generateReportPrompt') + ' (Y/N): '); if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { await this.generateReport(allChanges, languages); } } if (!args.dryRun) { console.log('\n' + t("complete.nextStepsTitle")); console.log(t("complete.separator")); console.log('1. Run usage analysis:'); console.log(' i18ntk --command=usage'); console.log('2. Validate translations:'); console.log(' i18ntk --command=validate'); console.log('3. Analyze translation status:'); console.log(' i18ntk --command=analyze'); console.log('\n' + t("complete.allKeysAvailable")); } else { console.log('\n' + t("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(t('common.completed')); await this.prompt(t('pressEnterToContinue')); } } catch (error) { console.error(t('complete.errorDuringCompletion', { error: error.message })); process.exit(1); } } } // Run if called directly if (require.main === module) { const tool = new I18nCompletionTool(); tool.run().then(() => { const { closeGlobalReadline } = require('../utils/cli'); closeGlobalReadline(); process.exit(0); }).catch(error => { const UIi18n = require('./i18ntk-ui'); console.error(t('complete.errorDuringCompletion', { error: error.message })); SecurityUtils.logSecurityEvent('I18n completion tool failed', 'error', { error: error.message }); const { closeGlobalReadline } = require('../utils/cli'); closeGlobalReadline(); process.exit(1); }); } module.exports = I18nCompletionTool;