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,128 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Auto-versioning script for i18n locale files
|
||||
* Bumps version based on changes and updates all catalogs consistently
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const I18N_DIR = join(new URL('.', import.meta.url).pathname, '..');
|
||||
|
||||
function parseVersion(versionStr) {
|
||||
const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
major: parseInt(match[1]),
|
||||
minor: parseInt(match[2]),
|
||||
patch: parseInt(match[3])
|
||||
};
|
||||
}
|
||||
|
||||
function formatVersion(version) {
|
||||
return `${version.major}.${version.minor}.${version.patch}`;
|
||||
}
|
||||
|
||||
function bumpVersion(version, type) {
|
||||
const v = parseVersion(version);
|
||||
if (!v) return version;
|
||||
|
||||
switch (type) {
|
||||
case 'major':
|
||||
return formatVersion({ major: v.major + 1, minor: 0, patch: 0 });
|
||||
case 'minor':
|
||||
return formatVersion({ major: v.major, minor: v.minor + 1, patch: 0 });
|
||||
case 'patch':
|
||||
default:
|
||||
return formatVersion({ major: v.major, minor: v.minor, patch: v.patch + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
function getChangeType() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--major')) return 'major';
|
||||
if (args.includes('--minor')) return 'minor';
|
||||
return 'patch';
|
||||
}
|
||||
|
||||
function updateLocaleFile(filePath, newVersion) {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
let catalog;
|
||||
|
||||
try {
|
||||
catalog = JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${filePath}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldVersion = catalog.version;
|
||||
catalog.version = newVersion;
|
||||
|
||||
// Preserve formatting with 2-space indentation
|
||||
const updatedContent = JSON.stringify(catalog, null, 2);
|
||||
|
||||
try {
|
||||
writeFileSync(filePath, updatedContent, 'utf-8');
|
||||
console.log(`✓ ${filePath}: ${oldVersion} → ${newVersion}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing ${filePath}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const changeType = getChangeType();
|
||||
|
||||
console.log(`🔧 Auto-versioning i18n catalogs (${changeType} bump)\n`);
|
||||
|
||||
// Get reference version from en-US.json
|
||||
const referencePath = join(I18N_DIR, 'en-US.json');
|
||||
let referenceVersion;
|
||||
|
||||
try {
|
||||
const reference = JSON.parse(readFileSync(referencePath, 'utf-8'));
|
||||
referenceVersion = reference.version;
|
||||
} catch (error) {
|
||||
console.error(`Cannot read reference locale: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!referenceVersion) {
|
||||
console.error('Reference locale has no version field');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const newVersion = bumpVersion(referenceVersion, changeType);
|
||||
console.log(`Current version: ${referenceVersion}`);
|
||||
console.log(`New version: ${newVersion}\n`);
|
||||
|
||||
// Update all locale files
|
||||
const files = readdirSync(I18N_DIR)
|
||||
.filter(f => f.endsWith('.json'));
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(I18N_DIR, file);
|
||||
if (updateLocaleFile(filePath, newVersion)) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n─────────────────────────────────────`);
|
||||
console.log(`Updated: ${successCount} files`);
|
||||
console.log(`Failed: ${failCount} files`);
|
||||
|
||||
if (failCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n✅ Version bump completed successfully!');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Locale validation script for i18n JSON files
|
||||
* Validates structure, required fields, and message format placeholders
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
|
||||
const I18N_DIR = join(new URL('.', import.meta.url).pathname, '..');
|
||||
const REQUIRED_FIELDS = ['version', 'language', 'fallback', 'messages'];
|
||||
const MESSAGE_CATEGORIES = ['errors', 'info', 'warnings', 'prompts', 'status', 'choices'];
|
||||
|
||||
function validateLocaleFile(filePath) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Cannot read file: ${error.message}`],
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
|
||||
let catalog;
|
||||
try {
|
||||
catalog = JSON.parse(content);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Invalid JSON: ${error.message}`],
|
||||
warnings: []
|
||||
};
|
||||
}
|
||||
|
||||
// Check required top-level fields
|
||||
for (const field of REQUIRED_FIELDS) {
|
||||
if (!(field in catalog)) {
|
||||
errors.push(`Missing required field: "${field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!catalog.version) {
|
||||
warnings.push('Version is empty or missing');
|
||||
} else if (!/^\d+\.\d+\.\d+$/.test(catalog.version)) {
|
||||
warnings.push(`Version "${catalog.version}" does not follow semver (X.Y.Z)`);
|
||||
}
|
||||
|
||||
if (!catalog.language || typeof catalog.language !== 'string') {
|
||||
errors.push('Field "language" must be a non-empty string');
|
||||
}
|
||||
|
||||
if (catalog.fallback !== null && typeof catalog.fallback !== 'string') {
|
||||
warnings.push('Field "fallback" should be null or a locale string');
|
||||
}
|
||||
|
||||
// Validate messages structure
|
||||
if (!catalog.messages || typeof catalog.messages !== 'object') {
|
||||
errors.push('Field "messages" must be an object');
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
|
||||
const messageKeys = Object.keys(catalog.messages);
|
||||
|
||||
// Check for proper key naming convention (category.key)
|
||||
const invalidKeys = messageKeys.filter(key => !key.includes('.'));
|
||||
if (invalidKeys.length > 0) {
|
||||
warnings.push(`Keys without category prefix: ${invalidKeys.slice(0, 5).join(', ')}`);
|
||||
}
|
||||
|
||||
// Check message categories coverage
|
||||
const foundCategories = new Set(messageKeys.map(k => k.split('.')[0]));
|
||||
const missingCategories = MESSAGE_CATEGORIES.filter(cat => !foundCategories.has(cat));
|
||||
if (missingCategories.length > 0) {
|
||||
warnings.push(`Missing message categories: ${missingCategories.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate message format placeholders consistency
|
||||
const placeholderPattern = /\{(\d+)\}/g;
|
||||
const messagesWithPlaceholders = {};
|
||||
|
||||
for (const [key, value] of Object.entries(catalog.messages)) {
|
||||
if (typeof value !== 'string') {
|
||||
errors.push(`Message "${key}" must be a string, got ${typeof value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.trim() === '') {
|
||||
warnings.push(`Message "${key}" is empty`);
|
||||
}
|
||||
|
||||
const matches = value.match(placeholderPattern);
|
||||
if (matches) {
|
||||
const indices = matches.map(m => parseInt(m.slice(1, -1)));
|
||||
const maxIndex = Math.max(...indices);
|
||||
const minIndex = Math.min(...indices);
|
||||
|
||||
if (minIndex !== 0) {
|
||||
warnings.push(`Message "${key}": placeholder indices should start at 0`);
|
||||
}
|
||||
|
||||
const expectedCount = maxIndex + 1;
|
||||
const uniqueIndices = new Set(indices);
|
||||
if (uniqueIndices.size !== expectedCount) {
|
||||
warnings.push(`Message "${key}": potentially missing placeholder indices (0-${maxIndex})`);
|
||||
}
|
||||
|
||||
messagesWithPlaceholders[key] = {
|
||||
count: uniqueIndices.size,
|
||||
maxIndex
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for consistent placeholder usage across locales (if reference exists)
|
||||
const refLocalePath = join(I18N_DIR, 'en-US.json');
|
||||
if (basename(filePath) !== 'en-US.json' && refLocalePath !== filePath) {
|
||||
try {
|
||||
const refContent = JSON.parse(readFileSync(refLocalePath, 'utf-8'));
|
||||
const refMessages = refContent.messages || {};
|
||||
|
||||
for (const [key, data] of Object.entries(messagesWithPlaceholders)) {
|
||||
if (refMessages[key]) {
|
||||
const refMatches = refMessages[key].match(placeholderPattern);
|
||||
if (refMatches) {
|
||||
const refIndices = new Set(refMatches.map(m => parseInt(m.slice(1, -1))));
|
||||
if (refIndices.size !== data.count) {
|
||||
warnings.push(`Message "${key}": placeholder count differs from en-US (${data.count} vs ${refIndices.size})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Reference locale may not exist
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
stats: {
|
||||
totalMessages: messageKeys.length,
|
||||
messagesWithPlaceholders: Object.keys(messagesWithPlaceholders).length,
|
||||
categories: Array.from(foundCategories)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const specificFile = args[0];
|
||||
|
||||
let filesToValidate = [];
|
||||
|
||||
if (specificFile) {
|
||||
filesToValidate = [join(I18N_DIR, specificFile)];
|
||||
} else {
|
||||
const files = readdirSync(I18N_DIR);
|
||||
filesToValidate = files
|
||||
.filter(f => f.endsWith('.json') && !f.startsWith('package'))
|
||||
.map(f => join(I18N_DIR, f));
|
||||
}
|
||||
|
||||
if (filesToValidate.length === 0) {
|
||||
console.error('No locale files found to validate');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let allValid = true;
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
|
||||
console.log('🔍 Validating locale files...\n');
|
||||
|
||||
for (const filePath of filesToValidate) {
|
||||
const fileName = basename(filePath);
|
||||
console.log(`📄 ${fileName}`);
|
||||
|
||||
const result = validateLocaleFile(filePath);
|
||||
|
||||
if (result.stats) {
|
||||
console.log(` Messages: ${result.stats.totalMessages}`);
|
||||
console.log(` Categories: ${result.stats.categories.join(', ')}`);
|
||||
console.log(` With placeholders: ${result.stats.messagesWithPlaceholders}`);
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log(` ❌ Errors (${result.errors.length}):`);
|
||||
result.errors.forEach(err => console.log(` - ${err}`));
|
||||
totalErrors += result.errors.length;
|
||||
allValid = false;
|
||||
} else {
|
||||
console.log(` ✅ No errors`);
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log(` ⚠️ Warnings (${result.warnings.length}):`);
|
||||
result.warnings.slice(0, 5).forEach(warn => console.log(` - ${warn}`));
|
||||
if (result.warnings.length > 5) {
|
||||
console.log(` ... and ${result.warnings.length - 5} more`);
|
||||
}
|
||||
totalWarnings += result.warnings.length;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('─'.repeat(50));
|
||||
console.log(`Summary: ${totalErrors} errors, ${totalWarnings} warnings`);
|
||||
|
||||
if (allValid) {
|
||||
console.log('✅ All locale files are valid!');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('❌ Validation failed. Please fix the errors above.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user