Files
AWatch-rus/i18n/scripts/check-coverage.js
T
qwen.ai[bot] dc422e2e2e 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.
2026-04-27 06:55:20 +00:00

228 lines
8.1 KiB
JavaScript

#!/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();