i18ntk
Version:
i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.
1,202 lines (1,022 loc) • 49.8 kB
JavaScript
/**
* I18NTK ANALYZE COMMAND
*
* Handles translation analysis functionality.
* Contains embedded business logic from I18nAnalyzer.
*/
const fs = require('fs');
const path = require('path');
const cliHelper = require('../../../utils/cli-helper');
const { loadTranslations, t } = require('../../../utils/i18n-helper');
const { getUnifiedConfig, parseCommonArgs, displayHelp, validateSourceDir } = require('../../../utils/config-helper');
const SecurityUtils = require('../../../utils/security');
const AdminCLI = require('../../../utils/admin-cli');
const AdminAuth = require('../../../utils/admin-auth');
const watchLocales = require('../../../utils/watch-locales');
const JsonOutput = require('../../../utils/json-output');
const configManager = require('../../../utils/config-manager');
const { normalizeReportFormat, writeReportFile } = require('../../../utils/report-writer');
loadTranslations('en', path.resolve(__dirname, '../../../ui-locales'));
const PROJECT_ROOT = process.cwd();
class AnalyzeCommand {
constructor(config = {}, ui = null) {
this.config = config;
this.ui = ui;
this.prompt = null;
this.isNonInteractiveMode = false;
this.safeClose = null;
// Initialize analysis properties
this.sourceDir = null;
this.sourceLanguageDir = null;
this.outputDir = null;
}
/**
* Set runtime dependencies for interactive operations
*/
setRuntimeDependencies(prompt, isNonInteractiveMode, safeClose) {
this.prompt = prompt;
this.isNonInteractiveMode = isNonInteractiveMode;
this.safeClose = safeClose;
}
/**
* Initialize the analyzer with configuration
*/
async initialize() {
try {
const args = this.parseArgs();
if (args.help) {
displayHelp('i18ntk-analyze', {
'language': 'Analyze specific language only',
'output-reports': 'Generate detailed reports',
'setup-admin': 'Configure admin PIN protection',
'disable-admin': 'Disable admin PIN protection',
'admin-status': 'Check admin PIN status',
'setup-wizard': 'Interactive setup wizard for analysis configuration',
'wizard': 'Alias for --setup-wizard',
'source': 'Source directory path',
'output': 'Output directory for reports',
'json': 'Output results as JSON',
'exclude-files': 'Comma-separated list of files to exclude',
'exclude': 'Pattern to exclude files'
});
process.exit(0);
}
// Configuration is handled by getUnifiedConfig - no need for .i18ntk directory check
// Initialize i18n with UI language first
const baseConfig = await getUnifiedConfig('analyze', 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);
this.outputDir = this.config.outputDir;
const explicitLocaleDir = args.i18nDir || (!args.i18nDir && args.sourceDir ? args.sourceDir : null);
if (explicitLocaleDir && !SecurityUtils.safeExistsSync(this.sourceDir, process.cwd())) {
const err = new Error(`Locale directory not found: ${this.sourceDir}`);
err.exitCode = 2;
throw err;
}
// Validate source directory exists and is readable/writable
this.validateSourceDirWithFallback(this.sourceDir);
} catch (error) {
SecurityUtils.logSecurityEvent('Analyze command initialization failed', 'error', {
component: 'AnalyzeCommand',
error: error.message
});
console.error(`Fatal analysis error: ${error.message}`);
throw error;
}
}
/**
* Validate source directory with fallback logic if the shared helper is unavailable.
* This prevents runtime crashes from missing imports or module regressions.
* @param {string} sourceDir
*/
validateSourceDirWithFallback(sourceDir) {
if (!sourceDir || typeof sourceDir !== 'string') {
throw new Error('Source directory is missing or invalid');
}
if (typeof validateSourceDir === 'function') {
validateSourceDir(sourceDir, 'i18ntk-analyze');
} else {
// Graceful fallback: create directory if needed.
const created = SecurityUtils.safeMkdirSync(sourceDir, process.cwd(), { recursive: true });
if (created) {
SecurityUtils.logSecurityEvent('Fallback source directory creation executed', 'warn', {
component: 'AnalyzeCommand',
sourceDir,
reason: 'validateSourceDir helper unavailable'
});
}
}
const safePath = SecurityUtils.validatePath(sourceDir, process.cwd());
if (!safePath) {
throw new Error(`Source directory path is unsafe: ${sourceDir}`);
}
const stat = SecurityUtils.safeStatSync(safePath, process.cwd());
if (!stat || !stat.isDirectory()) {
throw new Error(`Source directory does not exist or is not a directory: ${safePath}`);
}
try {
const stat = SecurityUtils.safeStatSync(safePath, process.cwd());
if (!stat || !stat.isDirectory()) {
throw new Error(`Source directory is not accessible: ${safePath}`);
}
} catch (error) {
throw new Error(`Insufficient permissions for source directory: ${safePath}`);
}
}
// Initialize readline interface (deprecated - use cliHelper directly)
initReadline() {
return cliHelper.getInterface();
}
// Close readline interface (deprecated - use cliHelper.close directly)
closeReadline() {
cliHelper.close();
}
// Prompt for user input
async prompt(question) {
return cliHelper.prompt(question);
}
// Parse command line arguments
parseArgs() {
try {
const args = process.argv.slice(2);
const parsed = parseCommonArgs(args);
// Add script-specific arguments
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 === 'language') {
parsed.language = sanitizedValue;
} else if (sanitizedKey === 'output-reports') {
parsed.outputReports = true;
} else if (sanitizedKey === 'setup-admin') {
parsed.setupAdmin = true;
} else if (sanitizedKey === 'disable-admin') {
parsed.disableAdmin = true;
} else if (sanitizedKey === 'admin-status') {
parsed.adminStatus = true;
} else if (sanitizedKey === 'json') {
parsed.json = true;
} else if (sanitizedKey === 'sort-keys') {
parsed.sortKeys = true;
} else if (sanitizedKey === 'indent') {
parsed.indent = parseInt(value) || 2;
} else if (sanitizedKey === 'newline') {
parsed.newline = value || 'lf';
} else if (sanitizedKey === 'setup-wizard' || sanitizedKey === 'wizard') {
parsed['setup-wizard'] = true;
parsed.wizard = true;
}
}
});
return parsed;
} catch (error) {
throw error;
}
}
// Get all available languages
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';
}
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) &&
this.isValidLanguageCode(name)
)
.filter(name => {
const dirPath = path.join(this.sourceDir, name);
const dirItems = SecurityUtils.safeReaddirSync(dirPath, process.cwd(), { withFileTypes: true }) || [];
return dirItems.some(item => item.isFile() && item.name.endsWith('.json'));
});
// 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) &&
this.isValidLanguageCode(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 [];
}
}
// Check for namespace-based structure (language.json files in any subdirectory)
const findNamespaceFiles = () => {
const results = [];
const searchDir = this.sourceDir;
try {
const searchDirExists = SecurityUtils.safeExistsSync(searchDir, this.sourceDir);
if (searchDirExists) {
const items = SecurityUtils.safeReaddirSync(searchDir, this.sourceDir, { withFileTypes: true });
if (items) {
for (const item of items) {
if (item.isDirectory() && !item.name.startsWith('.') && item.name !== 'node_modules') {
const namespaceDir = path.join(searchDir, item.name);
const namespaceFile = path.join(namespaceDir, `${language}.json`);
const namespaceFileExists = SecurityUtils.safeExistsSync(namespaceFile, this.sourceDir);
if (namespaceFileExists) {
results.push(path.relative(path.join(this.sourceDir, item.name), namespaceFile));
}
}
}
}
}
} catch (error) {
console.warn(`Error searching for namespace files: ${error.message}`);
}
return results;
};
const namespaceFiles = findNamespaceFiles();
if (namespaceFiles.length > 0) {
return namespaceFiles;
}
return files;
}
// Get all keys recursively from an object
getAllKeys(obj, prefix = '') {
const keys = new Set();
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
// Log a warning instead of crashing
console.warn(`⚠️ Skipping invalid translation object at prefix '${prefix}'`);
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) {
// Ensure keyPath is a string
const keyPathStr = String(keyPath || '');
const keys = keyPathStr.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;
}
// Analyze translation issues in an object
analyzeTranslationIssues(obj, sourceObj = null, prefix = '') {
const issues = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const sourceValue = sourceObj ? this.getValueByPath(sourceObj, fullKey) : null;
if (value && typeof value === 'object' && !Array.isArray(value)) {
issues.push(...this.analyzeTranslationIssues(value, sourceObj, fullKey));
} else if (typeof value === 'string') {
const markers = this.config.notTranslatedMarkers || [this.config.notTranslatedMarker];
if (markers.some(m => value === m)) {
issues.push({
type: 'not_translated',
key: fullKey,
value,
sourceValue: sourceValue || 'N/A'
});
} else if (value === '') {
issues.push({
type: 'empty_value',
key: fullKey,
value,
sourceValue: sourceValue || 'N/A'
});
} else if (markers.some(m => value.includes(m))) {
issues.push({
type: 'partial_translation',
key: fullKey,
value,
sourceValue: sourceValue || 'N/A'
});
} else if (sourceValue && value === sourceValue) {
issues.push({
type: 'same_as_source',
key: fullKey,
value,
sourceValue
});
}
}
}
return issues;
}
// Get translation statistics for an object
getTranslationStats(obj) {
let total = 0;
let translated = 0;
let notTranslated = 0;
let empty = 0;
let partial = 0;
const markers = this.config.notTranslatedMarkers || [this.config.notTranslatedMarker];
const count = (item) => {
if (typeof item === 'string') {
total++;
if (markers.some(m => item === m)) {
notTranslated++;
} else if (item === '') {
empty++;
} else if (markers.some(m => item.includes(m))) {
partial++;
} else {
translated++;
}
} else if (Array.isArray(item)) {
item.forEach(count);
} else if (item && typeof item === 'object') {
Object.values(item).forEach(count);
}
};
count(obj);
return {
total,
translated,
notTranslated,
empty,
partial,
percentage: total > 0 ? Math.round((translated / total) * 100) : 0,
missing: notTranslated + empty + partial
};
}
// Check structural consistency between source and target
checkStructuralConsistency(sourceObj, targetObj) {
const sourceKeys = this.getAllKeys(sourceObj);
const targetKeys = this.getAllKeys(targetObj);
const missingKeys = [...sourceKeys].filter(key => !targetKeys.has(key));
const extraKeys = [...targetKeys].filter(key => !sourceKeys.has(key));
return {
isConsistent: missingKeys.length === 0 && extraKeys.length === 0,
missingKeys,
extraKeys,
sourceKeyCount: sourceKeys.size,
targetKeyCount: targetKeys.size
};
}
// Analyze a single language
analyzeLanguage(language) {
const languageDir = path.join(this.sourceDir, language);
const sourceFiles = this.getLanguageFiles(this.config.sourceLanguage);
const targetFiles = this.getLanguageFiles(language);
const analysis = {
language,
files: {},
summary: {
totalFiles: sourceFiles.length,
analyzedFiles: 0,
totalKeys: 0,
translatedKeys: 0,
missingKeys: 0,
issues: []
}
};
for (const fileName of sourceFiles) {
const sourceFilePath = path.join(this.config.sourceLanguage, fileName);
const targetFilePath = path.join(language, fileName);
const sourceFullPath = path.join(this.sourceDir, sourceFilePath);
const targetFullPath = path.join(this.sourceDir, targetFilePath);
const sourceExists = SecurityUtils.safeExistsSync(sourceFullPath, this.sourceDir);
if (!sourceExists) {
continue;
}
let sourceContent, targetContent;
try {
const sourceFileContent = SecurityUtils.safeReadFileSync(sourceFullPath, this.sourceDir, 'utf8');
if (!sourceFileContent) {
analysis.files[fileName] = {
error: `Failed to read source file: File not accessible or empty`
};
continue;
}
sourceContent = SecurityUtils.safeParseJSON(sourceFileContent);
if (!sourceContent) {
analysis.files[fileName] = {
error: `Failed to parse source file: Invalid JSON format`
};
continue;
}
} catch (error) {
analysis.files[fileName] = {
error: `Failed to parse source file: ${error.message}`
};
continue;
}
const targetExists = SecurityUtils.safeExistsSync(targetFullPath, this.sourceDir);
if (!targetExists) {
analysis.files[fileName] = {
status: 'missing',
sourceKeys: this.getAllKeys(sourceContent).size
};
continue;
}
try {
const targetFileContent = SecurityUtils.safeReadFileSync(targetFullPath, this.sourceDir, 'utf8');
if (!targetFileContent) {
analysis.files[fileName] = {
error: `Failed to read target file: File not accessible or empty`
};
continue;
}
const parsed = SecurityUtils.safeParseJSON(targetFileContent);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
analysis.files[fileName] = {
error: `Invalid structure in target file: must be a plain object (not array/null/type)`
};
continue;
}
targetContent = parsed;
} catch (error) {
analysis.files[fileName] = {
error: `Failed to parse target file: ${error.message}`
};
continue;
}
// Analyze this file
const stats = this.getTranslationStats(targetContent);
const structural = this.checkStructuralConsistency(sourceContent, targetContent);
const issues = this.analyzeTranslationIssues(targetContent, sourceContent);
analysis.files[fileName] = {
status: 'analyzed',
stats,
structural,
issues,
sourceFilePath,
targetFilePath
};
// Update summary
analysis.summary.analyzedFiles++;
analysis.summary.totalKeys += stats.total;
analysis.summary.translatedKeys += stats.translated;
analysis.summary.missingKeys += stats.missing;
analysis.summary.issues.push(...issues);
}
// Calculate overall percentage
analysis.summary.percentage = analysis.summary.totalKeys > 0
? Math.round((analysis.summary.translatedKeys / analysis.summary.totalKeys) * 100)
: 0;
return analysis;
}
// Generate detailed report for a language
generateLanguageReport(analysis) {
const { language } = analysis;
const timestamp = new Date().toISOString();
let report = `${t('analyze.reportTitle', { language: language.toUpperCase() })}
`;
report += `${t('analyze.generated', { timestamp })}
`;
report += `${t('analyze.status', { translated: analysis.summary.translatedKeys, total: analysis.summary.totalKeys, percentage: analysis.summary.percentage })}
`;
report += `${t('analyze.filesAnalyzed', { analyzed: analysis.summary.analyzedFiles, total: analysis.summary.totalFiles })}
`;
report += `${t('analyze.keysNeedingTranslation', { count: analysis.summary.missingKeys })}
`;
report += `${t('analyze.fileBreakdown')}
`;
report += `${'='.repeat(50)}\n\n`;
Object.entries(analysis.files).forEach(([fileName, fileData]) => {
report += `\uD83D\uDCC4 ${fileName}\n`;
if (fileData.error) {
report += ` \u274C ${t('analyze.error')}: ${fileData.error}\n\n`;
return;
}
if (fileData.status === 'missing') {
report += ` \u274C ${t('analyze.statusFileMissing')}\n`;
report += ` 📊 ${t('analyze.sourceKeys', { count: fileData.sourceKeys })}\n\n`;
return;
}
const { stats, structural, issues } = fileData;
report += ` \uD83D\uDCCA ${t('analyze.translation', { translated: stats.translated, total: stats.total, percentage: stats.percentage })}\n`;
report += ` \uD83C\uDFD7️ ${t('analyze.structure', { status: structural.isConsistent ? t('analyze.consistent') : t('analyze.inconsistent') })}\n`;
if (!structural.isConsistent) {
if (structural.missingKeys.length > 0) {
report += ` ${t('analyze.missingKeys', { count: structural.missingKeys.length })}\n`;
}
if (structural.extraKeys.length > 0) {
report += ` ${t('analyze.extraKeys', { count: structural.extraKeys.length })}\n`;
}
}
if (issues.length > 0) {
report += ` ⚠️ ${t('analyze.issues', { count: issues.length })}\n`;
const issueTypes = {
not_translated: issues.filter(i => i.type === 'not_translated').length,
empty_value: issues.filter(i => i.type === 'empty_value').length,
partial_translation: issues.filter(i => i.type === 'partial_translation').length,
same_as_source: issues.filter(i => i.type === 'same_as_source').length
};
Object.entries(issueTypes).forEach(([type, count]) => {
if (count > 0) {
report += ` ${t('analyze.issueType.' + type, { count })}\n`;
}
});
}
report += `\n`;
});
// Keys needing translation
const notTranslatedIssues = analysis.summary.issues.filter(issue =>
issue.type === 'not_translated' || issue.type === 'empty_value'
);
if (notTranslatedIssues.length > 0) {
report += `${t('analyze.keysToTranslate')}\n`;
report += `${'='.repeat(50)}\n\n`;
notTranslatedIssues.slice(0, 50).forEach(issue => {
report += `${t('analyze.key')}: ${issue.key}\n`;
report += `${t('analyze.english')}: "${issue.sourceValue}"\n`;
report += `${language}: [${t('analyze.needsTranslation')}]\n\n`;
});
if (notTranslatedIssues.length > 50) {
report += `${t('analyze.andMoreKeys', { count: notTranslatedIssues.length - 50 })}\n\n`;
}
}
return report;
}
// Save analysis report to a file
async saveReport(language, report) {
try {
// Ensure we have a valid output directory
if (!this.outputDir) {
this.outputDir = path.join(process.cwd(), 'i18n-reports');
console.warn(`No output directory specified, using default: ${this.outputDir}`);
}
// Ensure the output directory exists
const dirCreated = SecurityUtils.safeMkdirSync(this.outputDir, process.cwd(), { recursive: true });
if (!dirCreated) {
console.error(`Failed to create output directory ${this.outputDir}`);
return null;
}
// Validate the output directory is within the project
const validatedOutputDir = SecurityUtils.validatePath(this.outputDir, process.cwd());
if (!validatedOutputDir) {
console.error(`Invalid output directory: ${this.outputDir} is outside project root`);
return null;
}
const safeLanguage = language.replace(/[^\w-]/g, '_');
const settings = configManager.getConfig ? configManager.getConfig() : {};
const format = normalizeReportFormat(this.config?.reports?.format || settings.reports?.format || this.config?.reportFormat || 'markdown');
const reportPath = await writeReportFile(validatedOutputDir, `translation-report-${safeLanguage}`, report, { format, title: `Translation Report ${safeLanguage.toUpperCase()}` });
return reportPath;
} catch (error) {
console.error(`Failed to save report for ${language}:`, error.message);
return null;
}
}
// Show help message
showHelp() {
console.log(t('analyze.help_message'));
}
provideSetupGuidance() {
return [
'Setup guidance:',
`- Ensure the locale root exists: ${this.sourceDir}`,
`- Ensure the source locale exists: ${path.join(this.sourceDir, this.config.sourceLanguage)}`,
'- Add at least one target locale, for example de/common.json or de.json.',
'- Prefer --locales-dir for locale files. Use --code-dir for application source files.'
].join('\n');
}
detectStructureType() {
const sourceLocaleDir = path.join(this.sourceDir, this.config.sourceLanguage);
const sourceLocaleFile = path.join(this.sourceDir, `${this.config.sourceLanguage}.json`);
if (SecurityUtils.safeExistsSync(sourceLocaleFile, this.sourceDir)) {
return { type: 'monolith' };
}
if (SecurityUtils.safeExistsSync(sourceLocaleDir, this.sourceDir)) {
return { type: 'directory' };
}
return { type: 'unknown' };
}
// Main analyze method
async analyze() {
try {
const results = [];
const args = this.parseArgs();
const jsonOutput = new JsonOutput('analyze');
if (!args.json) {
console.log(t('analyze.starting') || '🔍 Starting translation analysis...');
console.log(t('analyze.sourceDirectoryLabel', { sourceDir: path.resolve(this.sourceDir) }));
console.log(t('analyze.sourceLanguageLabel', { sourceLanguage: this.config.sourceLanguage }));
console.log(t('analyze.strictModeLabel', { mode: this.config.processing?.strictMode || this.config.strictMode ? 'ON' : 'OFF' }));
}
// Ensure output directory exists
const outputDirExists = SecurityUtils.safeExistsSync(this.outputDir, process.cwd());
if (!outputDirExists) {
SecurityUtils.safeMkdirSync(this.outputDir, process.cwd(), { recursive: true });
}
const languages = this.getAvailableLanguages();
if (languages.length === 0) {
const error = t('analyze.noLanguages') || '⚠️ No target languages found.';
const guidance = this.provideSetupGuidance();
if (args.json) {
jsonOutput.setStatus('error', error);
jsonOutput.setOutput({
error,
guidance,
structure: this.detectStructureType()
});
console.log(JSON.stringify(jsonOutput.data, null, args.indent || 2));
return;
}
console.log(error);
console.log('\n' + guidance);
return { success: true, warnings: 1, message: error };
}
if (!args.json) {
console.log(t('analyze.foundLanguages', { count: languages.length, languages: languages.join(', ') }) || `📋 Found ${languages.length} languages to analyze: ${languages.join(', ')}`);
}
let totalMissing = 0;
let totalExtra = 0;
let totalFiles = 0;
for (const language of languages) {
if (!args.json) {
console.log(t('analyze.analyzing', { language }) || `\n🔄 Analyzing ${language}...`);
}
const analysis = this.analyzeLanguage(language);
const report = this.generateLanguageReport(analysis);
// Save report
const reportPath = await this.saveReport(language, report);
if (!reportPath) {
throw new Error(`Failed to save analysis report for ${language}`);
}
const processedCount = results.length + 1;
if (!args.json) {
console.log(t('analyze.completed', { language }) || `✅ Analysis completed for ${language}`);
console.log(t('analyze.progress', {
translated: processedCount,
total: languages.length
}) || ` Progress: ${processedCount}/${languages.length} languages processed`);
console.log(t('analyze.reportSaved', { reportPath }) || ` Report saved: ${reportPath}`);
}
results.push({
language,
analysis,
reportPath
});
// Add issues to JSON output
Object.values(analysis.files).forEach(fileData => {
if (fileData.structural) {
fileData.structural.missingKeys?.forEach(key => {
jsonOutput.addIssue('missing', key, language);
totalMissing++;
});
fileData.structural.extraKeys?.forEach(key => {
jsonOutput.addIssue('extra', key, language);
totalExtra++;
});
}
});
totalFiles += analysis.summary.analyzedFiles;
}
// Set JSON output
jsonOutput.setStats({
missing: totalMissing,
extra: totalExtra,
files: totalFiles,
languages: languages.length
});
if (totalMissing > 0 || totalExtra > 0) {
jsonOutput.setStatus('warn');
} else {
jsonOutput.setStatus('ok');
}
if (args.json) {
jsonOutput.output();
return results;
}
// Summary
console.log(t('analyze.summary') || '\n📊 ANALYSIS SUMMARY');
console.log('='.repeat(50));
results.forEach(({ language, analysis }) => {
console.log(t('analyze.languageStats', {
language,
percentage: analysis.summary.percentage,
translated: analysis.summary.translatedKeys,
total: analysis.summary.totalKeys
}) || `${language}: ${analysis.summary.percentage}% complete (${analysis.summary.translatedKeys}/${analysis.summary.totalKeys} keys)`);
});
console.log(t('analyze.finished') || '\n✅ Analysis completed successfully!');
// Only prompt for input if running standalone and not in no-prompt mode
if (require.main === module && !this.noPrompt && process.env.CI !== 'true' && process.stdin.isTTY && process.stdout.isTTY) {
await this.prompt('\nPress Enter to continue...');
}
this.closeReadline();
return results;
} catch (error) {
console.error(t('analyze.error') || '❌ Analysis failed:', error.message);
this.closeReadline();
throw error;
}
}
// Main analysis process
async run(options = {}) {
const fromMenu = options.fromMenu || false;
try {
const args = this.parseArgs();
if (args.help) {
this.showHelp();
return;
}
// Handle setup wizard
if (args['setup-wizard'] || args.wizard) {
return await this.runSetupWizard();
}
// Initialize configuration properly when called from menu
if (fromMenu && !this.sourceDir) {
const baseConfig = await getUnifiedConfig('analyze', args);
this.config = { ...baseConfig, ...this.config };
const uiLanguage = 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);
this.outputDir = this.config.outputDir;
}
// Skip admin authentication when called from menu system (i18ntk-manage.js) or when --no-prompt is used
// Authentication is handled by the menu system
const isCalledDirectly = require.main === module;
if (isCalledDirectly && !args.noPrompt && !fromMenu) {
// Only check admin authentication when running directly and not in no-prompt mode
const adminAuth = new AdminAuth();
await adminAuth.initialize();
const isRequired = await adminAuth.isAuthRequired();
if (isRequired) {
console.log('\n' + t('adminCli.authRequiredForOperation', { operation: 'analyze translations' }));
const pin = await cliHelper.promptPin(t('adminCli.enterPin'));
const isValid = await this.adminAuth.verifyPin(pin);
if (!isValid) {
console.log(t('adminCli.invalidPin'));
this.closeReadline();
if (!fromMenu) process.exit(1);
return;
}
console.log(t('adminCli.authenticationSuccess'));
}
}
// Set noPrompt flag - skip prompts when called from menu
this.noPrompt = args.noPrompt || fromMenu;
// Handle UI language change
if (args.uiLanguage) {
loadTranslations(args.uiLanguage, path.resolve(__dirname, '../../../ui-locales'));}
// Update config if source directory is provided
const localeDirArg = args.i18nDir || (!args.i18nDir && args.sourceDir ? args.sourceDir : null);
if (localeDirArg) {
this.config.i18nDir = localeDirArg;
this.sourceDir = path.resolve(PROJECT_ROOT, localeDirArg);
this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage);
}
if (args.outputDir) {
this.config.outputDir = args.outputDir;
this.outputDir = path.resolve(this.config.outputDir);
}
const execute = async () => {
return await this.analyze();
};
if (args.watch) {
await execute();
let running = false;
watchLocales(this.sourceDir, async () => {
if (running) return;
running = true;
try {
await execute();
} finally {
running = false;
}
});
console.log('��� Watching for translation changes. Press Ctrl+C to exit.');
} else {
const result = await execute();
if (!fromMenu && require.main === module) {
process.exit(0);
}
return result;
}
} catch (error) {
console.error(t('analyze.error') || '❌ Analysis failed:', error.message);
this.closeReadline();
if (!fromMenu && require.main === module) {
process.exit(1);
}
throw error;
}
}
async runSetupWizard() {
console.log('Translation Analysis Setup Wizard');
console.log('='.repeat(50));
try {
const structure = this.detectStructureType();
console.log(`Current structure detected: ${structure.type}`);
const structureOptions = ['monolith', 'directory', 'namespace', 'mixed'];
const defaultStructureIndex = structureOptions.indexOf(structure.type);
const defaultStructureChoice = defaultStructureIndex >= 0 ? String(defaultStructureIndex + 1) : '4';
console.log('Choose your translation file structure:');
console.log('1) Monolith files (en.json, de.json, etc.)');
console.log('2) Directory structure (en/common.json, de/common.json)');
console.log('3) Namespace structure (common/en.json, forms/en.json)');
console.log('4) Mixed structure (auto-detect)');
const structureChoiceInput = await this.prompt(`Select option [${defaultStructureChoice}]: `);
const structureChoice = structureChoiceInput.trim() || defaultStructureChoice;
const structureType = structureOptions[Number(structureChoice) - 1] || 'mixed';
const sourceDirInput = await this.prompt(`Enter source directory path [${this.sourceDir}]: `);
const sourceDir = sourceDirInput.trim() || this.sourceDir;
if (!SecurityUtils.safeExistsSync(sourceDir, process.cwd())) {
console.log('Setup cancelled: directory does not exist.');
return { success: false, cancelled: true };
}
const languagesInput = await this.prompt('Enter languages to analyze (comma-separated) [de,fr,es,ja,ru]: ');
const languagesValue = languagesInput.trim() || 'de,fr,es,ja,ru';
const languages = languagesValue.split(',').map(lang => lang.trim()).filter(Boolean);
if (languages.length === 0) {
console.log('Setup cancelled: no languages provided.');
return { success: false, cancelled: true };
}
const outputReportsInput = await this.prompt('Generate detailed reports for each language? (Y/n): ');
const outputReports = !['n', 'no'].includes(outputReportsInput.trim().toLowerCase());
const outputDirInput = await this.prompt(`Enter output directory for reports [${this.outputDir}]: `);
const outputDir = outputDirInput.trim() || this.outputDir;
const response = {
structureType,
sourceDir,
languages: languagesValue,
outputReports,
outputDir
};
this.sourceDir = path.resolve(response.sourceDir);
this.outputDir = path.resolve(response.outputDir);
this.outputReports = response.outputReports;
console.log('\nConfiguration Summary:');
console.log(`Source: ${this.sourceDir}`);
console.log(`Output: ${this.outputDir}`);
console.log(`Languages: ${languages.join(", ")}`);
console.log(`Structure: ${response.structureType}`);
const proceedInput = await this.prompt('Proceed with analysis? (Y/n): ');
const proceed = !['n', 'no'].includes(proceedInput.trim().toLowerCase());
if (!proceed) {
console.log('Setup cancelled.');
return { success: false, cancelled: true };
}
const results = [];
for (const language of languages) {
try {
console.log(`\nAnalyzing ${language}...`);
const result = await this.analyzeLanguage(language);
results.push({ language, ...result });
if (this.outputReports) {
await this.saveReport(language, result);
console.log(`Report saved: ${language}.json`);
}
} catch (error) {
console.error(`Error analyzing ${language}:`, error.message);
results.push({ language, error: error.message });
}
}
const summary = {
totalLanguages: results.length,
successful: results.filter(r => !r.error).length,
failed: results.filter(r => r.error).length,
results
};
await this.saveReport('wizard-summary', {
summary,
configuration: response,
timestamp: new Date().toISOString()
});
console.log('\nSetup complete.');
console.log(`Analyzed ${summary.successful}/${summary.totalLanguages} languages successfully`);
return {
success: true,
summary,
configuration: response
};
} catch (error) {
console.error('Setup wizard error:', error.message);
return { success: false, error: error.message };
}
}
/**
* Execute the analyze command
*/
async execute(options = {}) {
try {
await this.initialize();
const result = await this.run(options);
return result && result.success === false ? result : { success: true, command: 'analyze', result };
} catch (error) {
console.error(`Analyze command failed: ${error.message}`);
throw error;
}
}
/**
* Get command metadata
*/
getMetadata() {
return {
name: 'analyze',
description: 'Analyze translation files for issues and completeness',
category: 'analysis',
aliases: [],
usage: 'analyze [options]',
examples: [
'analyze',
'analyze --source-dir=./src/locales',
'analyze --output-dir=./reports'
]
};
}
}
module.exports = AnalyzeCommand;