UNPKG

i18ntk

Version:

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

782 lines (656 loc) 30.8 kB
#!/usr/bin/env node /** * I18NTK FIXER COMMAND * * Handles translation fixing operations. * Contains embedded business logic for fixing translation issues. */ const path = require('path'); const fs = require('fs'); const SecurityUtils = require('../../../utils/security'); const cliHelper = require('../../../utils/cli-helper'); const { loadTranslations, t } = require('../../../utils/i18n-helper'); const { getUnifiedConfig, parseCommonArgs, displayHelp } = require('../../../utils/config-helper'); const JsonOutput = require('../../../utils/json-output'); const SetupEnforcer = require('../../../utils/setup-enforcer'); const { scanEnglishPlaceholders } = require('../../../utils/english-placeholder-checker'); class FixerCommand { constructor(config = {}, ui = null) { this.config = config; this.ui = ui; this.prompt = null; this.isNonInteractiveMode = false; this.safeClose = null; // Initialize fixer properties this.sourceDir = null; this.outputDir = null; this.backupDir = null; this.dryRun = false; this.force = false; } isExcludedLanguageDirectory(name) { if (!name || typeof name !== 'string') return true; const lowered = name.toLowerCase(); return lowered.startsWith('backup-') || lowered === 'backup' || lowered === 'backups' || lowered === 'i18ntk-backups' || lowered === 'reports' || lowered === 'i18ntk-reports'; } /** * Set runtime dependencies for interactive operations */ setRuntimeDependencies(prompt, isNonInteractiveMode, safeClose) { this.prompt = prompt; this.isNonInteractiveMode = isNonInteractiveMode; this.safeClose = safeClose; } /** * Initialize the fixer with configuration */ async initialize() { try { const args = this.parseArgs(); if (args.help) { displayHelp('i18ntk-fixer', { 'source-dir': 'Source directory to scan (default: ./locales)', 'languages': 'Comma separated list of languages to fix', 'markers': 'Comma separated markers to treat as untranslated', 'check-placeholders': 'Only check English locale files for [LANG] placeholder leftovers', 'no-backup': 'Skip automatic backup creation', 'dry-run': 'Show what would be fixed without making changes', 'force': 'Force fixes without confirmation', 'output-dir': 'Output directory for fixed files' }); process.exit(0); } const baseConfig = await getUnifiedConfig('fixer', 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.sourceDir; this.outputDir = this.config.outputDir; this.backupDir = path.resolve(this.config.backup?.location || './i18ntk-backups', 'fixer'); // Validate source directory exists const { validateSourceDir } = require('../../../utils/config-helper'); validateSourceDir(this.sourceDir, 'i18ntk-fixer'); } catch (error) { console.error(`Fatal fixer error: ${error.message}`); throw error; } } parseArgs() { try { const args = process.argv.slice(2); const parsed = parseCommonArgs(args); args.forEach(arg => { if (arg.startsWith('--')) { const [key, value] = arg.substring(2).split('='); const sanitizedKey = SecurityUtils.sanitizeInput(key); const sanitizedValue = value ? SecurityUtils.sanitizeInput(value) : true; if (sanitizedKey === 'source-dir') { parsed.sourceDir = sanitizedValue; } else if (sanitizedKey === 'languages') { parsed.languages = sanitizedValue.split(',').map(l => l.trim()); } else if (sanitizedKey === 'markers') { parsed.markers = sanitizedValue.split(',').map(m => m.trim()); } else if (sanitizedKey === 'check-placeholders') { parsed.checkPlaceholders = true; } else if (sanitizedKey === 'no-backup') { parsed.noBackup = true; } else if (sanitizedKey === 'dry-run') { parsed.dryRun = true; } else if (sanitizedKey === 'force') { parsed.force = true; } else if (sanitizedKey === 'output-dir') { parsed.outputDir = sanitizedValue; } } }); return parsed; } catch (error) { throw error; } } // Get all available languages getAvailableLanguages() { try { const items = SecurityUtils.safeReaddirSync(this.sourceDir, process.cwd(), { withFileTypes: true }); if (!items) { console.error('Error reading source directory: Unable to access directory'); return []; } const languages = []; // Check for directory-based structure const directories = items .filter(item => item.isDirectory()) .map(item => item.name) .filter(name => name !== 'node_modules' && !name.startsWith('.') && name !== this.config.sourceLanguage && !this.isExcludedLanguageDirectory(name) ); // Check for monolith files (language.json files) const files = items .filter(item => item.isFile() && item.name.endsWith('.json')) .map(item => item.name); // Add directories as languages languages.push(...directories); // Add monolith files as languages (without .json extension) const monolithLanguages = files .map(file => file.replace('.json', '')) .filter(lang => !languages.includes(lang) && lang !== this.config.sourceLanguage && !this.isExcludedLanguageDirectory(lang) ); languages.push(...monolithLanguages); return [...new Set(languages)].sort(); } catch (error) { console.error('Error reading source directory:', error.message); return []; } } // Get all JSON files from a language directory getLanguageFiles(language) { if (!this.sourceDir) { console.warn('Source directory not set'); return []; } const languageDir = path.resolve(this.sourceDir, language); const languageFile = path.resolve(this.sourceDir, `${language}.json`); const files = []; // Handle monolith file structure const languageFileStat = SecurityUtils.safeStatSync(languageFile, this.sourceDir); if (languageFileStat && languageFileStat.isFile()) { return [path.basename(languageFile)]; } // Handle directory-based structure const languageDirStat = SecurityUtils.safeStatSync(languageDir, this.sourceDir); if (languageDirStat && languageDirStat.isDirectory()) { try { // Ensure the path is within the source directory for security const validatedPath = SecurityUtils.validatePath(languageDir, this.sourceDir); if (!validatedPath) { console.warn(`Language directory not found or invalid: ${languageDir}`); return []; } const findJsonFiles = (dir) => { const results = []; const items = SecurityUtils.safeReaddirSync(dir, this.sourceDir, { withFileTypes: true }); if (!items) return results; for (const item of items) { const fullPath = path.join(dir, item.name); if (item.isDirectory() && !item.name.startsWith('.') && item.name !== 'node_modules') { // Recursively search subdirectories results.push(...findJsonFiles(fullPath)); } else if (item.isFile() && item.name.endsWith('.json')) { // Check exclusion patterns const relativePath = path.relative(this.sourceDir, fullPath); const shouldExclude = (this.config.excludeFiles || []).some(pattern => { if (typeof pattern === 'string') { return relativePath === pattern || relativePath.endsWith(path.sep + pattern); } if (pattern instanceof RegExp) { return pattern.test(relativePath); } return false; }); if (!shouldExclude && !item.name.startsWith('.')) { results.push(path.relative(languageDir, fullPath)); } } } return results; }; return findJsonFiles(validatedPath); } catch (error) { console.error(`Error reading language directory ${languageDir}:`, error.message); return []; } } return files; } // Get all keys recursively from an object getAllKeys(obj, prefix = '') { const keys = new Set(); if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { return keys; } for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; keys.add(fullKey); if (value && typeof value === 'object' && !Array.isArray(value)) { const nestedKeys = this.getAllKeys(value, fullKey); nestedKeys.forEach(k => keys.add(k)); } } return keys; } // Get value by key path getValueByPath(obj, keyPath) { const keys = keyPath.split('.'); let current = obj; for (const key of keys) { if (current && typeof current === 'object' && key in current) { current = current[key]; } else { return undefined; } } return current; } // Set value by key path setValueByPath(obj, keyPath, value) { const keys = keyPath.split('.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!current[key] || typeof current[key] !== 'object') { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; } // Create backup of translation files async createBackup() { if (this.dryRun) return; try { const backupEnabled = this.config?.backup?.enabled === true; if (!backupEnabled) { this.backupDir = null; return; } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupRoot = path.resolve(this.config?.backup?.location || './i18ntk-backups'); this.backupDir = path.join(backupRoot, 'fixer', `backup-${timestamp}`); console.log(t('fixer.creatingBackup', { dir: this.backupDir })); // Ensure backup directory exists const dirCreated = SecurityUtils.safeMkdirSync(this.backupDir, process.cwd(), { recursive: true }); if (!dirCreated) { console.warn('Failed to create backup directory'); return; } // Copy all translation files to backup const languages = this.getAvailableLanguages(); languages.push(this.config.sourceLanguage); // Include source language for (const language of languages) { const languageFiles = this.getLanguageFiles(language); for (const fileName of languageFiles) { const sourcePath = path.join(this.sourceDir, language, fileName); const backupPath = path.join(this.backupDir, language, fileName); // Ensure backup subdirectory exists const backupSubDir = path.dirname(backupPath); SecurityUtils.safeMkdirSync(backupSubDir, process.cwd(), { recursive: true }); // Copy file if (SecurityUtils.safeExistsSync(sourcePath, this.sourceDir)) { const content = SecurityUtils.safeReadFileSync(sourcePath, this.sourceDir, 'utf8'); SecurityUtils.safeWriteFileSync(backupPath, content, process.cwd(), 'utf8'); } } } this.cleanupOldBackups(); console.log(t('fixer.backupCreated')); } catch (error) { console.warn(`Failed to create backup: ${error.message}`); } } cleanupOldBackups() { try { const fixerBackupRoot = path.resolve(this.config?.backup?.location || './i18ntk-backups', 'fixer'); if (!SecurityUtils.safeExistsSync(fixerBackupRoot, process.cwd())) return; const configuredKeep = parseInt(this.config?.backup?.maxBackups, 10); const keepCount = Number.isInteger(configuredKeep) ? Math.min(Math.max(configuredKeep, 1), 3) : 1; const backupDirs = SecurityUtils.safeReaddirSync(fixerBackupRoot, process.cwd(), { withFileTypes: true }) .filter(entry => entry.isDirectory() && entry.name.startsWith('backup-')) .map(entry => { const dirPath = path.join(fixerBackupRoot, entry.name); const stat = SecurityUtils.safeStatSync(dirPath, process.cwd()); return { name: entry.name, path: dirPath, mtimeMs: stat ? stat.mtimeMs : 0 }; }) .sort((a, b) => b.mtimeMs - a.mtimeMs); if (backupDirs.length <= keepCount) return; for (const staleDir of backupDirs.slice(keepCount)) { try { SecurityUtils.safeUnlinkSync(staleDir.path, process.cwd()); } catch (_) { // Directory not empty, use recursive removal try { const { rmSync } = require('fs'); rmSync(staleDir.path, { recursive: true, force: true }); } catch (_) { // Best-effort cleanup } } } } catch (error) { console.warn(`Failed to clean old backups: ${error.message}`); } } // Analyze translation issues for fixing analyzeIssues(language, fileName) { const issues = []; const sourceFiles = this.getLanguageFiles(this.config.sourceLanguage); const targetFiles = this.getLanguageFiles(language); if (!sourceFiles.includes(fileName) || !targetFiles.includes(fileName)) { return issues; } const sourceFilePath = path.join(this.sourceDir, this.config.sourceLanguage, fileName); const targetFilePath = path.join(this.sourceDir, language, fileName); try { const sourceContent = SecurityUtils.safeReadFileSync(sourceFilePath, this.sourceDir, 'utf8'); const targetContent = SecurityUtils.safeReadFileSync(targetFilePath, this.sourceDir, 'utf8'); if (!sourceContent || !targetContent) { return issues; } const sourceObj = SecurityUtils.safeParseJSON(sourceContent); const targetObj = SecurityUtils.safeParseJSON(targetContent); if (!sourceObj || !targetObj) { return issues; } const sourceKeys = this.getAllKeys(sourceObj); for (const key of sourceKeys) { const sourceValue = this.getValueByPath(sourceObj, key); const targetValue = this.getValueByPath(targetObj, key); if (targetValue === undefined) { // Missing key issues.push({ type: 'missing_key', key, sourceValue, fix: (obj) => this.setValueByPath(obj, key, sourceValue) }); } else if (targetValue === '') { // Empty value issues.push({ type: 'empty_value', key, sourceValue, fix: (obj) => this.setValueByPath(obj, key, sourceValue) }); } else { const markers = this.config.notTranslatedMarkers || [this.config.notTranslatedMarker]; if (markers.some(m => targetValue === m)) { // Untranslated marker issues.push({ type: 'untranslated_marker', key, sourceValue, fix: (obj) => this.setValueByPath(obj, key, sourceValue) }); } } } return issues; } catch (error) { console.warn(`Error analyzing ${language}/${fileName}: ${error.message}`); return issues; } } // Fix translation issues for a language async fixLanguage(language) { const fixes = { language, files: {}, totalIssues: 0, fixedIssues: 0 }; const sourceFiles = this.getLanguageFiles(this.config.sourceLanguage); for (const fileName of sourceFiles) { const issues = this.analyzeIssues(language, fileName); if (issues.length > 0) { fixes.files[fileName] = { issues: issues.length, fixed: 0 }; fixes.totalIssues += issues.length; if (!this.dryRun) { // Apply fixes const targetFilePath = path.join(this.sourceDir, language, fileName); try { const targetContent = SecurityUtils.safeReadFileSync(targetFilePath, this.sourceDir, 'utf8'); if (!targetContent) continue; const targetObj = SecurityUtils.safeParseJSON(targetContent); if (!targetObj) continue; for (const issue of issues) { if (typeof issue.fix === 'function') { issue.fix(targetObj); fixes.files[fileName].fixed++; fixes.fixedIssues++; } } // Write back the fixed content const fixedContent = JSON.stringify(targetObj, null, 2); SecurityUtils.safeWriteFileSync(targetFilePath, fixedContent, this.sourceDir, 'utf8'); } catch (error) { console.warn(`Error fixing ${language}/${fileName}: ${error.message}`); } } else { // In dry run mode, just count potential fixes fixes.files[fileName].fixed = issues.length; fixes.fixedIssues += issues.length; } } } return fixes; } checkEnglishPlaceholders(options = {}) { const result = scanEnglishPlaceholders({ sourceDir: this.sourceDir || this.config.sourceDir, sourceLanguage: this.config.sourceLanguage || 'en' }); if (options.print === false) { return result; } console.log('\nEnglish placeholder check'); console.log('-'.repeat(50)); console.log(`Source directory: ${result.sourceDir}`); console.log(`Source language: ${result.sourceLanguage}`); console.log(`Files scanned: ${result.fileCount}`); console.log(`Keys scanned: ${result.keyCount}`); console.log(`Language-code placeholders found: ${result.placeholderCount}`); if (result.errors.length > 0) { console.log('\nParse errors:'); for (const item of result.errors.slice(0, 20)) { console.log(` ${item.file}: ${item.error}`); } if (result.errors.length > 20) { console.log(` ... and ${result.errors.length - 20} more parse errors.`); } } if (result.placeholders.length > 0) { console.log('\nWARNING: English locale files still contain language-code placeholders.'); for (const item of result.placeholders.slice(0, 20)) { console.log(` ${item.file} :: ${item.key} = ${JSON.stringify(item.value)}`); } if (result.placeholders.length > 20) { console.log(` ... and ${result.placeholders.length - 20} more placeholders.`); } console.log('Recommendation: translate or replace these English source values before fixing target locales.'); } else if (result.errors.length === 0) { console.log('OK: English placeholder count is 0.'); } return result; } // Main fixing process async fix() { try { const args = this.parseArgs(); const jsonOutput = new JsonOutput('fixer'); // Set options from args this.dryRun = args.dryRun || false; this.force = args.force || false; const placeholderCheck = this.checkEnglishPlaceholders({ print: !args.json }); if (args.checkPlaceholders) { if (args.json) { jsonOutput.setStatus(placeholderCheck.success ? 'ok' : 'error'); jsonOutput.setStats({ files: placeholderCheck.fileCount, keys: placeholderCheck.keyCount, placeholders: placeholderCheck.placeholderCount, parseErrors: placeholderCheck.errors.length }); jsonOutput.data.message = 'English placeholder check completed'; jsonOutput.data.placeholders = placeholderCheck.placeholders; jsonOutput.data.errors = placeholderCheck.errors; console.log(JSON.stringify(jsonOutput.data, null, args.indent || 2)); } return placeholderCheck; } if (!placeholderCheck.success) { const error = `English locale placeholder check failed: ${placeholderCheck.placeholderCount} placeholder(s), ${placeholderCheck.errors.length} parse error(s).`; if (args.json) { jsonOutput.setStatus('error'); jsonOutput.setStats({ placeholders: placeholderCheck.placeholderCount, parseErrors: placeholderCheck.errors.length }); jsonOutput.data.message = error; jsonOutput.data.placeholders = placeholderCheck.placeholders; jsonOutput.data.errors = placeholderCheck.errors; console.log(JSON.stringify(jsonOutput.data, null, args.indent || 2)); return { ...placeholderCheck, success: false, error }; } console.log(`\n${error}`); return { ...placeholderCheck, success: false, error }; } const languages = this.getAvailableLanguages(); if (!args.json) { console.log(t('fixer.starting', { languages: languages.join(', ') || 'none' })); console.log(t('fixer.sourceDirectory', { sourceDir: path.resolve(this.sourceDir) })); console.log(t('fixer.dryRunMode', { mode: this.dryRun ? 'ON' : 'OFF' })); } // Create backup unless disabled if (!args.noBackup && !this.dryRun && this.config?.backup?.enabled === true) { await this.createBackup(); } if (languages.length === 0) { const error = t('fixer.noLanguages') || 'No target languages found.'; if (args.json) { jsonOutput.setStatus('error'); jsonOutput.data.message = error; console.log(JSON.stringify(jsonOutput.data, null, args.indent || 2)); return; } console.log(error); return; } if (!args.json) { console.log(t('fixer.foundLanguages', { count: languages.length, languages: languages.join(', ') })); } const results = {}; let totalIssues = 0; let totalFixed = 0; for (const language of languages) { if (!args.json) { console.log(t('fixer.fixing', { language })); } const fixes = await this.fixLanguage(language); results[language] = fixes; totalIssues += fixes.totalIssues; totalFixed += fixes.fixedIssues; if (!args.json) { const skipped = Math.max(0, fixes.totalIssues - fixes.fixedIssues); console.log(t('fixer.languageFixed', { language, issues: fixes.totalIssues, fixed: fixes.fixedIssues, skipped })); } } // Prepare JSON output if (args.json) { jsonOutput.setStatus(totalFixed > 0 ? 'ok' : 'info'); jsonOutput.setStats({ issues: totalIssues, fixed: totalFixed, languages: languages.length }); jsonOutput.data.message = 'Fixer completed'; console.log(JSON.stringify(jsonOutput.data, null, args.indent || 2)); return { success: true, totalIssues, totalFixed, results }; } // Summary console.log(t('fixer.summary')); console.log('='.repeat(50)); console.log(t('fixer.totalIssues', { totalIssues })); console.log(t('fixer.totalFixed', { count: totalFixed })); if (this.backupDir && !args.noBackup && this.config?.backup?.enabled === true) { console.log(t('fixer.backupLocation', { dir: this.backupDir })); } console.log(t('fixer.completed')); return { success: true, totalIssues, totalFixed, results }; } catch (error) { console.error(t('fixer.error', { error: error.message })); throw error; } } // Main run method for compatibility async run(options = {}) { const fromMenu = options.fromMenu || false; try { const args = this.parseArgs(); if (args.help) { this.showHelp(); return; } // Initialize configuration properly when called from menu if (fromMenu && !this.sourceDir) { const baseConfig = await getUnifiedConfig('fixer', args); this.config = { ...baseConfig, ...this.config }; const uiLanguage = this.config.uiLanguage || 'en'; loadTranslations(uiLanguage, path.resolve(__dirname, '../../../ui-locales')); this.sourceDir = this.config.sourceDir; this.outputDir = this.config.outputDir; } return await this.fix(); } catch (error) { console.error(t('fixer.error', { error: error.message })); if (!fromMenu) { process.exit(1); } } } // Show help message showHelp() { console.log(t('fixer.help_message')); } /** * Execute the fixer command */ async execute(options = {}) { try { await this.initialize(); const result = await this.run(options); if (result && result.success === false) { return result; } return { success: true, command: 'fix', result }; } catch (error) { console.error(`Fixer command failed: ${error.message}`); throw error; } } /** * Get command metadata */ getMetadata() { return { name: 'fix', description: 'Automatically fix translation issues and inconsistencies', category: 'maintenance', aliases: ['fixer'], usage: 'fix [options]', examples: [ 'fix', 'fix --dry-run', 'fix --backup' ] }; } } module.exports = FixerCommand;