Title: Add i18n module with Russian localization and validation tools
Key features implemented: - New i18n module with JSON-based message catalogs for multi-language support - Added Russian (ru-RU) and English (en-US) localization files with 100+ messages - PowerShell module ActivityWatch.Windows.I18n.psm1 with fallback mechanism - Node.js validation and coverage analysis scripts for locale management - Package.json configuration with semantic versioning and build tools - README_RU.md documentation with integration examples and best practices The implementation provides comprehensive internationalization support with automated validation, versioning, and fallback capabilities for robust multilingual deployments.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Coverage analysis script for i18n locales
|
||||
* Compares all locales against the reference (en-US) and reports coverage
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
|
||||
const I18N_DIR = join(new URL('.', import.meta.url).pathname, '..');
|
||||
const REFERENCE_LOCALE = 'en-US.json';
|
||||
|
||||
function loadLocale(fileName) {
|
||||
const filePath = join(I18N_DIR, fileName);
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
function analyzeCoverage() {
|
||||
const files = readdirSync(I18N_DIR)
|
||||
.filter(f => f.endsWith('.json') && f !== REFERENCE_LOCALE && !f.startsWith('package'));
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error('No locale files found besides the reference');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let reference;
|
||||
try {
|
||||
reference = loadLocale(REFERENCE_LOCALE);
|
||||
} catch (error) {
|
||||
console.error(`Cannot load reference locale (${REFERENCE_LOCALE}): ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const referenceKeys = Object.keys(reference.messages || {});
|
||||
const referenceCategories = categorizeKeys(referenceKeys);
|
||||
|
||||
console.log('📊 i18n Coverage Analysis\n');
|
||||
console.log(`Reference: ${REFERENCE_LOCALE} (${referenceKeys.length} messages)\n`);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const fileName of files) {
|
||||
const localeName = basename(fileName, '.json');
|
||||
|
||||
let catalog;
|
||||
try {
|
||||
catalog = loadLocale(fileName);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
locale: localeName,
|
||||
error: error.message
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const localeKeys = Object.keys(catalog.messages || {});
|
||||
const localeCategories = categorizeKeys(localeKeys);
|
||||
|
||||
const missingKeys = referenceKeys.filter(k => !localeKeys.includes(k));
|
||||
const extraKeys = localeKeys.filter(k => !referenceKeys.includes(k));
|
||||
|
||||
const coverage = referenceKeys.length > 0
|
||||
? ((referenceKeys.length - missingKeys.length) / referenceKeys.length * 100).toFixed(2)
|
||||
: 0;
|
||||
|
||||
const categoryCoverage = {};
|
||||
for (const [category, refKeys] of Object.entries(referenceCategories)) {
|
||||
const localeCatKeys = localeCategories[category] || [];
|
||||
const missing = refKeys.filter(k => !localeCatKeys.includes(k));
|
||||
categoryCoverage[category] = {
|
||||
total: refKeys.length,
|
||||
translated: refKeys.length - missing.length,
|
||||
missing: missing.length,
|
||||
percent: refKeys.length > 0
|
||||
? ((refKeys.length - missing.length) / refKeys.length * 100).toFixed(1)
|
||||
: 100
|
||||
};
|
||||
}
|
||||
|
||||
results.push({
|
||||
locale: localeName,
|
||||
version: catalog.version,
|
||||
language: catalog.language,
|
||||
fallback: catalog.fallback,
|
||||
totalMessages: localeKeys.length,
|
||||
referenceMessages: referenceKeys.length,
|
||||
missing: missingKeys,
|
||||
extra: extraKeys,
|
||||
coverage: parseFloat(coverage),
|
||||
categoryCoverage
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by coverage descending
|
||||
results.sort((a, b) => (b.coverage || 0) - (a.coverage || 0));
|
||||
|
||||
// Print summary table
|
||||
console.log('┌' + '─'.repeat(78) + '┐');
|
||||
console.log('│ ' + padRight('Locale', 12) + ' │ ' +
|
||||
padRight('Version', 10) + ' │ ' +
|
||||
padRight('Language', 10) + ' │ ' +
|
||||
padRight('Coverage', 10) + ' │ ' +
|
||||
padRight('Missing', 10) + ' │ ' +
|
||||
padRight('Extra', 10) + ' │');
|
||||
console.log('├' + '─'.repeat(78) + '┤');
|
||||
|
||||
for (const result of results) {
|
||||
if (result.error) {
|
||||
console.log('│ ' + padRight(result.locale, 12) + ' │ ERROR: ' + result.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const coverageStr = result.coverage.toFixed(2) + '%';
|
||||
const coverageColor = getCoverageIndicator(result.coverage);
|
||||
|
||||
console.log('│ ' + padRight(result.locale, 12) + ' │ ' +
|
||||
padRight(result.version || 'N/A', 10) + ' │ ' +
|
||||
padRight(result.language || 'N/A', 10) + ' │ ' +
|
||||
padRight(coverageStr + coverageColor, 10) + ' │ ' +
|
||||
padRight(result.missing.length.toString(), 10) + ' │ ' +
|
||||
padRight(result.extra.length.toString(), 10) + ' │');
|
||||
}
|
||||
|
||||
console.log('└' + '─'.repeat(78) + '┘\n');
|
||||
|
||||
// Detailed breakdown per locale
|
||||
for (const result of results) {
|
||||
if (result.error || result.missing.length === 0) continue;
|
||||
|
||||
console.log(`📋 ${result.locale} - Missing Keys (${result.missing.length}):`);
|
||||
|
||||
// Group by category
|
||||
const missingByCategory = {};
|
||||
for (const key of result.missing) {
|
||||
const category = key.split('.')[0];
|
||||
if (!missingByCategory[category]) {
|
||||
missingByCategory[category] = [];
|
||||
}
|
||||
missingByCategory[category].push(key);
|
||||
}
|
||||
|
||||
for (const [category, keys] of Object.entries(missingByCategory)) {
|
||||
console.log(` ${category} (${keys.length}):`);
|
||||
keys.slice(0, 10).forEach(key => console.log(` - ${key}`));
|
||||
if (keys.length > 10) {
|
||||
console.log(` ... and ${keys.length - 10} more`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Category coverage summary
|
||||
console.log('📈 Category Coverage Summary:\n');
|
||||
const categories = Object.keys(referenceCategories);
|
||||
|
||||
console.log('┌' + '─'.repeat(68) + '┐');
|
||||
console.log('│ ' + padRight('Category', 20) + ' │ ' +
|
||||
padRight('Ref Count', 12) + ' │ ' +
|
||||
'Average Coverage' + ' │');
|
||||
console.log('├' + '─'.repeat(68) + '┤');
|
||||
|
||||
for (const category of categories) {
|
||||
const avgCoverage = results
|
||||
.filter(r => !r.error && r.categoryCoverage[category])
|
||||
.reduce((sum, r) => sum + parseFloat(r.categoryCoverage[category].percent), 0) /
|
||||
Math.max(results.filter(r => !r.error).length, 1);
|
||||
|
||||
console.log('│ ' + padRight(category, 20) + ' │ ' +
|
||||
padRight(referenceCategories[category].length.toString(), 12) + ' │ ' +
|
||||
padRight(avgCoverage.toFixed(1) + '%', 16) + ' │');
|
||||
}
|
||||
|
||||
console.log('└' + '─'.repeat(68) + '┘\n');
|
||||
|
||||
// Recommendations
|
||||
console.log('💡 Recommendations:\n');
|
||||
|
||||
const lowCoverageLocales = results.filter(r => !r.error && r.coverage < 80);
|
||||
if (lowCoverageLocales.length > 0) {
|
||||
console.log('1. Priority locales for translation:');
|
||||
lowCoverageLocales.forEach(r => {
|
||||
console.log(` - ${r.locale}: ${r.missing.length} keys missing (${r.coverage.toFixed(1)}% coverage)`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const allHaveFallback = results.every(r => !r.error && r.fallback);
|
||||
if (!allHaveFallback) {
|
||||
console.log('2. Consider adding fallback locale to all catalogs for better resilience');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const versionMismatch = results.filter(r => !r.error && r.version !== reference.version);
|
||||
if (versionMismatch.length > 0) {
|
||||
console.log('3. Version mismatch detected. Consider bumping versions:');
|
||||
versionMismatch.forEach(r => {
|
||||
console.log(` - ${r.locale}: ${r.version} (reference: ${reference.version})`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
function categorizeKeys(keys) {
|
||||
const categories = {};
|
||||
for (const key of keys) {
|
||||
const category = key.split('.')[0];
|
||||
if (!categories[category]) {
|
||||
categories[category] = [];
|
||||
}
|
||||
categories[category].push(key);
|
||||
}
|
||||
return categories;
|
||||
}
|
||||
|
||||
function padRight(str, length) {
|
||||
return (str || '').toString().padEnd(length, ' ');
|
||||
}
|
||||
|
||||
function getCoverageIndicator(coverage) {
|
||||
if (coverage >= 95) return ' ✅';
|
||||
if (coverage >= 80) return ' ⚠️';
|
||||
return ' ❌';
|
||||
}
|
||||
|
||||
analyzeCoverage();
|
||||
Reference in New Issue
Block a user