i18ntk
Version:
i18ntk (i18n Toolkit) - A comprehensive, enterprise-grade internationalization (i18n) management toolkit for JavaScript/TypeScript projects with advanced analysis, validation, and automation features
749 lines (648 loc) • 25.8 kB
JavaScript
const fs = require('fs');
const path = require('path');
const settingsManager = require('../settings/settings-manager');
const SecurityUtils = require('../utils/security');
const UIi18n = require('./i18ntk-ui');
// Get configuration from settings manager
function getConfig() {
const settings = settingsManager.getSettings();
// Check for per-script directory override, fallback to global sourceDir
const sourceDir = settings.scriptDirectories?.summary || settings.sourceDir || null;
return {
sourceLanguage: settings.sourceLanguage || 'en',
excludeFiles: settings.processing?.excludeFiles || ['index.js', 'index.ts', '.DS_Store'],
supportedExtensions: settings.processing?.supportedExtensions || ['.json', '.js', '.ts'],
sourceDir: sourceDir,
uiLanguage: settings.language || 'en'
};
}
/**
* I18N SUMMARY REPORT GENERATOR
*
* Analyzes the i18n folder structure and generates a comprehensive summary report
* including key statistics, file structure analysis, and validation checks.
*
* This script is designed to be generic and work with any i18n project structure.
*/
class I18nSummaryReporter {
constructor() {
this.config = getConfig();
this.ui = new UIi18n();
this.t = this.ui.t.bind(this.ui);
this.stats = {
languages: [],
totalFiles: 0,
totalKeys: 0,
keysByLanguage: {},
filesByLanguage: {},
missingFiles: [],
inconsistentKeys: [],
emptyFiles: [],
malformedFiles: [],
duplicateKeys: []
};
this.rl = null;
}
// 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 = {
sourceDir: null,
outputFile: null,
verbose: false,
help: false,
keepReports: false,
deleteReports: false,
noPrompt: false
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else if (arg === '--verbose' || arg === '-v') {
parsed.verbose = true;
} else if (arg === '--source-dir' || arg === '-s') {
parsed.sourceDir = args[++i];
} else if (arg === '--output' || arg === '-o') {
parsed.outputFile = args[++i];
} else if (arg === '--keep-reports') {
parsed.keepReports = true;
} else if (arg === '--delete-reports') {
parsed.deleteReports = true;
} else if (arg === '--no-prompt') {
parsed.noPrompt = true;
}
}
return parsed;
}
// Show help information
showHelp() {
console.log(`\n${this.t('summary.helpTitle')}\n`);
console.log(this.t('summary.helpDescription') + '\n');
console.log(this.t('summary.helpUsage') + '\n');
console.log(this.t('summary.helpOptions'));
console.log(this.t('summary.helpSourceDir'));
console.log(this.t('summary.helpOutput'));
console.log(this.t('summary.helpVerbose'));
console.log(this.t('summary.helpKeepReports'));
console.log(this.t('summary.helpDeleteReports'));
console.log(this.t('summary.helpHelp') + '\n');
console.log(this.t('summary.helpExamples'));
console.log(this.t('summary.helpExample1'));
console.log(this.t('summary.helpExample2'));
console.log(this.t('summary.helpExample3'));
console.log(this.t('summary.helpExample4'));
console.log(this.t('summary.helpExample5') + '\n');
}
// Auto-detect i18n directory
detectI18nDirectory() {
const possiblePaths = [
'locales',
'i18ntk-reports',
'src/i18n/locales',
'src/locales',
'src/i18n',
'i18n/locales',
'i18n',
'public/locales',
'assets/i18n',
'translations'
];
for (const possiblePath of possiblePaths) {
const fullPath = path.resolve(possiblePath);
if (fs.existsSync(fullPath)) {
const contents = fs.readdirSync(fullPath);
// Check if it contains language directories
const hasLanguageDirs = contents.some(item => {
const itemPath = path.join(fullPath, item);
return fs.statSync(itemPath).isDirectory() &&
item.length === 2 && // Language codes are typically 2 characters
/^[a-z]{2}$/.test(item);
});
if (hasLanguageDirs) {
return fullPath;
}
}
}
return null;
}
// Get all available languages
getAvailableLanguages() {
if (!fs.existsSync(this.config.sourceDir)) {
return [];
}
// Check for monolith JSON files (en.json, es.json, etc.)
const files = fs.readdirSync(this.config.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.config.sourceDir)
.filter(item => {
const itemPath = path.join(this.config.sourceDir, item);
return fs.statSync(itemPath).isDirectory() &&
!item.startsWith('.') &&
item !== 'node_modules';
});
return [...new Set([...languages, ...directories])].sort();
}
// Get all translation files for a language
getLanguageFiles(language) {
const languageDir = path.join(this.config.sourceDir, language);
if (!fs.existsSync(languageDir)) {
return [];
}
return fs.readdirSync(languageDir)
.filter(file => {
return this.config.supportedExtensions.some(ext => file.endsWith(ext)) &&
!this.config.excludeFiles.includes(file);
})
.sort();
}
// Extract keys from a translation file
async extractKeysFromFile(filePath) {
try {
const content = await SecurityUtils.safeReadFile(filePath, this.config.sourceDir);
if (!content) {
console.warn(this.t('summary.couldNotReadFile', { filePath }));
return [];
}
if (filePath.endsWith('.json')) {
const data = JSON.parse(content);
return this.extractKeysFromObject(data);
} else if (filePath.endsWith('.js') || filePath.endsWith('.ts')) {
// Basic extraction for JS/TS files (assumes export default or module.exports)
const match = content.match(/(?:export\s+default|module\.exports\s*=)\s*({[\s\S]*})/);;
if (match) {
const objStr = match[1];
// This is a simplified approach - in production, you might want to use a proper JS parser
try {
const data = eval(`(${objStr})`);
return this.extractKeysFromObject(data);
} catch (e) {
console.warn(this.t('summary.couldNotParseJSFile', { filePath }));
return [];
}
}
}
return [];
} catch (error) {
console.warn(this.t('summary.errorReadingFile', { filePath, error: error.message }));
return [];
}
}
// Extract keys recursively from an object
extractKeysFromObject(obj, prefix = '') {
const keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
keys.push(...this.extractKeysFromObject(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys;
}
// Check if file is empty or has no meaningful content
async isFileEmpty(filePath) {
try {
const content = await SecurityUtils.safeReadFile(filePath, this.config.sourceDir);
if (!content) return true;
const trimmedContent = content.trim();
if (!trimmedContent) return true;
if (filePath.endsWith('.json')) {
const data = SecurityUtils.safeParseJSON(trimmedContent);
if (!data) return true;
return Object.keys(data).length === 0;
}
return false;
} catch (error) {
return false; // If we can't parse it, it's not empty, just malformed
}
}
// Check if file is malformed
async isFileMalformed(filePath) {
try {
const content = await SecurityUtils.safeReadFile(filePath, this.config.sourceDir);
if (!content) return true;
if (filePath.endsWith('.json')) {
const parsed = SecurityUtils.safeParseJSON(content);
if (!parsed) return true;
}
return false;
} catch (error) {
return true;
}
}
// Find duplicate keys within a file
async findDuplicateKeys(filePath) {
try {
const content = await SecurityUtils.safeReadFile(filePath, this.config.sourceDir);
if (!content) return [];
if (filePath.endsWith('.json')) {
// For JSON files, check for duplicate keys in the raw content
const keys = [];
const keyRegex = /"([^"]+)"\s*:/g;
let match;
while ((match = keyRegex.exec(content)) !== null) {
keys.push(match[1]);
}
const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index);
return [...new Set(duplicates)];
}
return [];
} catch (error) {
return [];
}
}
// Analyze folder structure and collect statistics
async analyzeStructure() {
console.log(this.t('summary.analyzingFolder'));
this.stats.languages = this.getAvailableLanguages();
if (this.stats.languages.length === 0) {
console.log(this.t('summary.noLanguageDirectoriesFound'));
return;
}
console.log(this.t('summary.foundLanguages', {count: this.stats.languages.length, languages: this.stats.languages.join(', ')}));
// Use first language as reference for file structure
const referenceLanguage = this.stats.languages[0];
const referenceFiles = this.getLanguageFiles(referenceLanguage);
console.log(this.t('summary.referenceLanguageFiles', {language: referenceLanguage, count: referenceFiles.length}));
// Analyze each language
for (const language of this.stats.languages) {
console.log(this.t('summary.analyzingLanguage', {language}));
const files = this.getLanguageFiles(language);
this.stats.filesByLanguage[language] = files;
this.stats.keysByLanguage[language] = {};
let totalKeysForLanguage = 0;
// Check for missing files compared to reference
const missingFiles = referenceFiles.filter(file => !files.includes(file));
if (missingFiles.length > 0) {
this.stats.missingFiles.push({
language,
files: missingFiles
});
}
// Analyze each file
for (const file of files) {
const filePath = path.join(this.config.sourceDir, language, file);
// Check if file is empty
if (await this.isFileEmpty(filePath)) {
this.stats.emptyFiles.push({ language, file });
}
// Check if file is malformed
if (await this.isFileMalformed(filePath)) {
this.stats.malformedFiles.push({ language, file });
continue;
}
// Find duplicate keys
const duplicates = await this.findDuplicateKeys(filePath);
if (duplicates.length > 0) {
this.stats.duplicateKeys.push({ language, file, duplicates });
}
// Extract keys
const keys = await this.extractKeysFromFile(filePath);
this.stats.keysByLanguage[language][file] = keys;
totalKeysForLanguage += keys.length;
this.stats.totalFiles++;
}
this.stats.totalKeys += totalKeysForLanguage;
console.log(this.t('summary.keysInFiles', {keys: totalKeysForLanguage, files: files.length}));
}
// Find inconsistent keys across languages
this.findInconsistentKeys();
}
// Find keys that are inconsistent across languages
findInconsistentKeys() {
console.log(this.t('summary.checkingInconsistentKeys'));
const referenceLanguage = this.stats.languages[0];
const referenceKeys = this.stats.keysByLanguage[referenceLanguage];
for (const file of Object.keys(referenceKeys)) {
const refKeys = new Set(referenceKeys[file]);
for (const language of this.stats.languages.slice(1)) {
const langKeys = this.stats.keysByLanguage[language][file] || [];
const langKeySet = new Set(langKeys);
// Find missing keys in this language
const missingInLang = [...refKeys].filter(key => !langKeySet.has(key));
// Find extra keys in this language
const extraInLang = [...langKeySet].filter(key => !refKeys.has(key));
if (missingInLang.length > 0 || extraInLang.length > 0) {
this.stats.inconsistentKeys.push({
file,
language,
missing: missingInLang,
extra: extraInLang
});
}
}
}
}
// Generate summary report
generateReport() {
const report = [];
const timestamp = new Date().toISOString();
report.push(this.t('summary.reportTitle'));
report.push('='.repeat(50));
report.push(this.t('summary.generated', {timestamp}));
report.push(this.t('summary.sourceDirectory', {dir: this.config.sourceDir}));
report.push('');
// Overview
report.push(this.t('summary.overview'));
report.push('='.repeat(30));
report.push(this.t('summary.languagesCount', {count: this.stats.languages.length}));
report.push(this.t('summary.totalFiles', {count: this.stats.totalFiles}));
report.push(this.t('summary.totalKeys', {count: this.stats.totalKeys}));
report.push(this.t('summary.avgKeysPerLanguage', {count: Math.round(this.stats.totalKeys / this.stats.languages.length)}));
report.push('');
// Languages breakdown
report.push(this.t('summary.languagesBreakdown'));
report.push('='.repeat(30));
for (const language of this.stats.languages) {
const files = this.stats.filesByLanguage[language];
const totalKeys = Object.values(this.stats.keysByLanguage[language])
.reduce((sum, keys) => sum + keys.length, 0);
report.push(this.t('summary.languageBreakdown', {language, files: files.length, keys: totalKeys}));
}
report.push('');
// File structure
report.push(this.t('summary.fileStructure'));
report.push('='.repeat(30));
const referenceLanguage = this.stats.languages[0];
const referenceFiles = this.stats.filesByLanguage[referenceLanguage] || [];
for (const file of referenceFiles) {
const keysInFile = this.stats.keysByLanguage[referenceLanguage][file]?.length || 0;
report.push(this.t('summary.fileKeys', {file, keys: keysInFile}));
// Show which languages have this file
const languagesWithFile = this.stats.languages.filter(lang =>
this.stats.filesByLanguage[lang].includes(file)
);
if (languagesWithFile.length < this.stats.languages.length) {
const missingIn = this.stats.languages.filter(lang =>
!languagesWithFile.includes(lang)
);
report.push(this.t('summary.missingInLanguages', {languages: missingIn.join(', ')}));
}
}
report.push('');
// Issues
if (this.stats.missingFiles.length > 0 ||
this.stats.emptyFiles.length > 0 ||
this.stats.malformedFiles.length > 0 ||
this.stats.duplicateKeys.length > 0 ||
this.stats.inconsistentKeys.length > 0) {
report.push(this.t('summary.issuesFound'));
report.push('='.repeat(30));
if (this.stats.missingFiles.length > 0) {
report.push(this.t('summary.missingFiles'));
for (const item of this.stats.missingFiles) {
report.push(` ${item.language}: ${item.files.join(', ')}`);
}
report.push('');
}
if (this.stats.emptyFiles.length > 0) {
report.push(this.t('summary.emptyFiles'));
for (const item of this.stats.emptyFiles) {
report.push(` ${item.language}/${item.file}`);
}
report.push('');
}
if (this.stats.malformedFiles.length > 0) {
report.push(this.t('summary.malformedFiles'));
for (const item of this.stats.malformedFiles) {
report.push(` ${item.language}/${item.file}`);
}
report.push('');
}
if (this.stats.duplicateKeys.length > 0) {
report.push(this.t('summary.duplicateKeys'));
for (const item of this.stats.duplicateKeys) {
report.push(` ${item.language}/${item.file}: ${item.duplicates.join(', ')}`);
}
report.push('');
}
if (this.stats.inconsistentKeys.length > 0) {
report.push(this.t('summary.inconsistentKeys'));
for (const item of this.stats.inconsistentKeys) {
report.push(` ${item.language}/${item.file}:`);
if (item.missing.length > 0) {
report.push(this.t('summary.missingKeys', {keys: item.missing.slice(0, 5).join(', '), more: item.missing.length > 5 ? '...' : ''}));
}
if (item.extra.length > 0) {
report.push(this.t('summary.extraKeys', {keys: item.extra.slice(0, 5).join(', '), more: item.extra.length > 5 ? '...' : ''}));
}
}
report.push('');
}
} else {
report.push(this.t('summary.noIssuesFound'));
report.push('='.repeat(30));
report.push(this.t('summary.allFilesConsistent'));
report.push('');
}
// Recommendations
report.push(this.t('summary.recommendations'));
report.push('='.repeat(30));
if (this.stats.missingFiles.length > 0) {
report.push(this.t('summary.createMissingFiles'));
}
if (this.stats.emptyFiles.length > 0) {
report.push(this.t('summary.addContentToEmptyFiles'));
}
if (this.stats.malformedFiles.length > 0) {
report.push(this.t('summary.fixMalformedFiles'));
}
if (this.stats.duplicateKeys.length > 0) {
report.push(this.t('summary.removeDuplicateKeys'));
}
if (this.stats.inconsistentKeys.length > 0) {
report.push(this.t('summary.synchronizeKeys'));
}
if (this.stats.totalKeys > 10000) {
report.push(this.t('summary.splitLargeFiles'));
}
if (this.stats.languages.length === 1) {
report.push(this.t('summary.addMoreLanguages'));
}
report.push('');
report.push(this.t('summary.nextSteps'));
report.push(this.t('summary.nextStep1'));
report.push(this.t('summary.nextStep2'));
report.push(this.t('summary.nextStep3'));
report.push(this.t('summary.nextStep4'));
return report.join('\n');
}
// Run the analysis and generate report
async run(options = {}) {
const args = this.parseArgs();
const { fromMenu = false, noPrompt = false } = options;
// Check admin authentication for sensitive operations (only when called directly)
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 && !noPrompt && !fromMenu && !args.noPrompt) {
console.log('\n' + this.t('adminCli.authRequiredForOperation', { operation: 'generate summary' }));
// Create readline interface for PIN input
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const pin = await new Promise(resolve => {
rl.question(this.t('adminCli.enterPin'), resolve);
});
const isValid = await adminAuth.verifyPin(pin);
rl.close();
if (!isValid) {
console.log(this.t('adminCli.invalidPin'));
process.exit(1);
}
console.log(this.t('adminCli.authenticationSuccess'));
}
if (args.help) {
this.showHelp();
return;
}
// Determine source directory
if (args.sourceDir) {
this.config.sourceDir = path.resolve(args.sourceDir);
} else {
this.config.sourceDir = this.detectI18nDirectory();
}
if (!this.config.sourceDir) {
console.error(this.t('summary.couldNotFindI18nDirectory'));
process.exit(1);
}
if (!fs.existsSync(this.config.sourceDir)) {
console.error(this.t('summary.sourceDirectoryDoesNotExist', { sourceDir: this.config.sourceDir }));
process.exit(1);
}
console.log(this.t('summary.i18nSummaryReportGenerator'));
console.log(this.t('summary.separator'));
console.log(this.t('summary.sourceDirectory', {dir: this.config.sourceDir}));
if (args.verbose) {
console.log(this.t('summary.configurationTitle'));
console.log(this.t('summary.sourceLanguage', { sourceLanguage: this.config.sourceLanguage }));
console.log(this.t('summary.supportedExtensions', { extensions: this.config.supportedExtensions.join(', ') }));
console.log(this.t('summary.excludedFiles', { files: this.config.excludeFiles.join(', ') }));
}
try {
// Analyze structure
await this.analyzeStructure();
// Generate report
console.log(this.t('summary.generatingSummaryReport'));
const report = this.generateReport();
// Output report
if (args.outputFile) {
// Always save summary reports to i18ntk-reports
const reportsDir = path.resolve(process.cwd(), 'i18ntk-reports');
if (!fs.existsSync(reportsDir)) {
fs.mkdirSync(reportsDir, { recursive: true });
}
const outputFileName = args.outputFile ? path.basename(args.outputFile) : `summary-report-${new Date().toISOString().slice(0,10)}.txt`;
const outputPath = path.join(reportsDir, outputFileName);
const success = await SecurityUtils.safeWriteFile(outputPath, report, reportsDir);
if (success) {
console.log(this.t('summary.reportSaved', { reportPath: outputPath }));
} else {
console.log(this.t('summary.reportSaveFailed', { reportPath: outputPath }));
}
} else {
console.log(this.t('summary.reportContent', { report }));
}
// Handle report file management
if (args.deleteReports && !args.keepReports) {
console.log(this.t('summary.cleaningUpReportFiles'));
try {
const reportsDir = path.join(this.config.sourceDir, 'scripts', 'i18n', 'reports');
if (fs.existsSync(reportsDir)) {
const files = fs.readdirSync(reportsDir);
const reportFiles = files.filter(file =>
(file.endsWith('.txt') || file.endsWith('.json') || file.endsWith('.log')) &&
file !== path.basename(args.outputFile || '')
);
let deletedCount = 0;
for (const file of reportFiles) {
try {
fs.unlinkSync(path.join(reportsDir, file));
deletedCount++;
} catch (error) {
console.log(this.t('summary.couldNotDelete', { file, error: error.message }));
}
}
if (deletedCount > 0) {
console.log(this.t('summary.deletedOldReportFiles', { count: deletedCount }));
} else {
console.log(this.t('summary.noOldReportFilesToDelete'));
}
}
} catch (error) {
console.log(this.t('summary.errorCleaningUpReports', { error: error.message }));
}
} else if (args.keepReports) {
console.log(this.t('summary.reportFilesPreserved'));
}
// Summary
console.log(this.t('summary.separator'));
console.log(this.t('summary.analysisComplete'));
console.log(this.t('summary.separator'));
console.log(this.t('summary.analyzedLanguages', { count: this.stats.languages.length }));
console.log(this.t('summary.processedFiles', { count: this.stats.totalFiles }));
console.log(this.t('summary.foundTranslationKeys', { count: this.stats.totalKeys }));
const totalIssues = this.stats.missingFiles.length +
this.stats.emptyFiles.length +
this.stats.malformedFiles.length +
this.stats.duplicateKeys.length +
this.stats.inconsistentKeys.length;
if (totalIssues > 0) {
console.log(this.t('summary.foundIssues', {count: totalIssues}));
} else {
console.log(this.t('summary.noIssuesConsole'));
}
} catch (error) {
console.error(this.t('summary.errorDuringAnalysis', { error: error.message }));
if (args.verbose) {
console.error(error.stack);
}
process.exit(1);
}
process.exit(0);
}
}
// Run if called directly
if (require.main === module) {
const reporter = new I18nSummaryReporter();
reporter.run().catch(error => {
console.error(this.t('summary.fatalError', { error: error.message }));
process.exit(1);
});
}
module.exports = I18nSummaryReporter;