UNPKG

i18ntk

Version:

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

450 lines (383 loc) 14.6 kB
/** * Summary Service * Handles summary report generation without circular dependencies * @module services/SummaryService */ const path = require('path'); const fs = require('fs'); const SecurityUtils = require('../../../utils/security'); const { loadTranslations, t } = require('../../../utils/i18n-helper'); const { getUnifiedConfig } = require('../../../utils/config-helper'); module.exports = class SummaryService { constructor(config = {}) { this.config = config; this.settings = null; this.configManager = null; this.stats = { languages: [], totalFiles: 0, totalKeys: 0, keysByLanguage: {}, filesByLanguage: {}, fileSizes: {}, folderSizes: {}, missingFiles: [], inconsistentKeys: [], emptyFiles: [], malformedFiles: [], duplicateKeys: [] }; } /** * Initialize the service with required dependencies * @param {Object} configManager - Configuration manager instance */ initialize(configManager) { this.configManager = configManager; this.settings = configManager.loadSettings ? configManager.loadSettings() : (configManager.getConfig ? configManager.getConfig() : {}); } /** * Run summary analysis and generate report * @param {Object} options - Execution options * @returns {Promise<string>} Generated report */ async run(options = {}) { try { // Get unified configuration const baseConfig = await getUnifiedConfig('summary', {}); this.config = { ...this.config, ...baseConfig }; // Load translations const uiLanguage = this.config.uiLanguage || 'en'; loadTranslations(uiLanguage); // Validate source directory if (!SecurityUtils.safeExistsSync(this.config.sourceDir, this.config.sourceDir)) { throw new Error(`Source directory does not exist: ${this.config.sourceDir}`); } // Analyze structure and generate report await this.analyzeStructure(); const report = this.generateReport(); return report; } catch (error) { console.error(`Error in SummaryService: ${error.message}`); throw error; } } /** * Get available languages from the source directory * @returns {string[]} Array of language codes */ getAvailableLanguages() { if (!SecurityUtils.safeExistsSync(this.config.sourceDir, this.config.sourceDir)) { return []; } // Check for monolith JSON files (en.json, es.json, etc.) const files = SecurityUtils.safeReaddirSync(this.config.sourceDir, 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); const stats = SecurityUtils.safeStatSync(itemPath, this.config.sourceDir); return stats && stats.isDirectory() && !item.startsWith('.') && item !== 'node_modules'; }); return [...new Set([...languages, ...directories])].sort(); } /** * Get translation files for a specific language * @param {string} language - Language code * @returns {string[]} Array of file names */ getLanguageFiles(language) { const languageDir = path.join(this.config.sourceDir, language); if (!SecurityUtils.safeExistsSync(languageDir, this.config.sourceDir)) { return []; } return SecurityUtils.safeReaddirSync(languageDir, this.config.sourceDir) || [] .filter(file => { return this.config.supportedExtensions.some(ext => file.endsWith(ext)) && !this.config.excludeFiles.includes(file); }) .sort(); } /** * Extract keys from a translation file * @param {string} filePath - Path to the translation file * @returns {Promise<string[]>} Array of translation keys */ async extractKeysFromFile(filePath) { try { const content = await SecurityUtils.safeReadFile(filePath, this.config.sourceDir); if (!content) { return []; } if (filePath.endsWith('.json')) { const data = JSON.parse(content); return this.extractKeysFromObject(data); } return []; } catch (error) { return []; } } /** * Extract keys recursively from an object * @param {Object} obj - Object to extract keys from * @param {string} prefix - Key prefix for nested objects * @returns {string[]} Array of keys */ 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; } /** * Analyze the translation file structure * @returns {Promise<void>} */ async analyzeStructure() { console.log(t('summary.analyzingFolder')); this.stats.languages = this.getAvailableLanguages(); if (this.stats.languages.length === 0) { console.log(t('summary.noLanguageDirectoriesFound')); return; } console.log(t('summary.foundLanguages', {count: this.stats.languages.length, languages: this.stats.languages.join(', ')})); // Analyze each language for (const language of this.stats.languages) { console.log(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 referenceLanguage = this.stats.languages[0]; const referenceFiles = this.getLanguageFiles(referenceLanguage); 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); // 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(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(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 table row for reports * @param {string[]} cells - Table cells * @param {number[]} widths - Column widths * @returns {string} Formatted table row */ generateTableRow(cells, widths) { const row = cells.map((cell, index) => { const width = widths[index] || 15; return String(cell).padEnd(width); }).join(' | '); return row; } /** * Generate table separator * @param {number[]} widths - Column widths * @returns {string} Table separator line */ generateTableSeparator(widths) { return widths.map(width => '-'.repeat(width)).join('-+-'); } /** * Format file size in human readable format * @param {number} bytes - File size in bytes * @returns {string} Formatted file size */ formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } /** * Generate the summary report * @returns {string} Complete report */ generateReport() { const report = []; const timestamp = new Date().toISOString(); report.push(t('summary.reportTitle')); report.push('='.repeat(50)); report.push(t('summary.generated', {timestamp})); report.push(t('summary.sourceDirectory', {dir: this.config.sourceDir})); report.push(''); // Overview report.push(t('summary.overview')); report.push('='.repeat(30)); report.push(t('summary.languagesCount', {count: this.stats.languages.length})); report.push(t('summary.totalFiles', {count: this.stats.totalFiles})); report.push(t('summary.totalKeys', {count: this.stats.totalKeys})); report.push(t('summary.avgKeysPerLanguage', {count: Math.round(this.stats.totalKeys / this.stats.languages.length)})); report.push(''); // Languages breakdown report.push(t('summary.languagesBreakdown')); report.push('='.repeat(30)); const langTableWidths = [12, 8, 8, 12]; report.push(this.generateTableRow(['Language', 'Files', 'Keys', 'Avg Keys/File'], langTableWidths)); report.push(this.generateTableSeparator(langTableWidths)); 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); const avgKeys = files.length > 0 ? Math.round(totalKeys / files.length) : 0; report.push(this.generateTableRow([ language.toUpperCase(), files.length, totalKeys, avgKeys ], langTableWidths)); } report.push(''); // File structure report.push(t('summary.fileStructure')); report.push('='.repeat(30)); const fileTableWidths = [20, 8, 12, 15, 15]; report.push(this.generateTableRow(['File', 'Keys', 'Languages', 'Missing In'], fileTableWidths)); report.push(this.generateTableSeparator(fileTableWidths)); 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; // Calculate languages with this file let languagesWithFile = []; let missingIn = []; for (const language of this.stats.languages) { if (this.stats.filesByLanguage[language].includes(file)) { languagesWithFile.push(language.toUpperCase()); } else { missingIn.push(language.toUpperCase()); } } report.push(this.generateTableRow([ file, keysInFile, languagesWithFile.join(', '), missingIn.join(', ') ], fileTableWidths)); } 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(t('summary.issuesFound')); report.push('='.repeat(30)); if (this.stats.missingFiles.length > 0) { report.push(t('summary.missingFiles')); for (const item of this.stats.missingFiles) { report.push(` ${item.language}: ${item.files.join(', ')}`); } report.push(''); } if (this.stats.inconsistentKeys.length > 0) { report.push(t('summary.inconsistentKeys')); for (const item of this.stats.inconsistentKeys) { report.push(` ${item.language}/${item.file}:`); if (item.missing.length > 0) { report.push(t('summary.missingKeys', {keys: item.missing.slice(0, 5).join(', '), more: item.missing.length > 5 ? '...' : ''})); } if (item.extra.length > 0) { report.push(t('summary.extraKeys', {keys: item.extra.slice(0, 5).join(', '), more: item.extra.length > 5 ? '...' : ''})); } } report.push(''); } } else { report.push(t('summary.noIssuesFound')); report.push('='.repeat(30)); report.push(t('summary.allFilesConsistent')); report.push(''); } // Recommendations report.push(t('summary.recommendations')); report.push('='.repeat(30)); if (this.stats.missingFiles.length > 0) { report.push(t('summary.createMissingFiles')); } if (this.stats.inconsistentKeys.length > 0) { report.push(t('summary.synchronizeKeys')); } if (this.stats.languages.length === 1) { report.push(t('summary.addMoreLanguages')); } report.push(''); report.push(t('summary.nextSteps')); report.push(t('summary.nextStep1')); report.push(t('summary.nextStep2')); report.push(t('summary.nextStep3')); report.push(t('summary.nextStep4')); return report.join('\n'); } /** * Get current configuration * @returns {Object} Current configuration */ getConfig() { return this.config; } /** * Get current settings * @returns {Object} Current settings */ getSettings() { return this.settings; } };