diff --git a/.gitignore b/.gitignore index 7eecd45..880ff80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,51 @@ -# Local secrets -secrets/deploy.secrets.env +``` +# Dependencies +node_modules/ +package-lock.json -# Runtime / reports +# Logs and temp files *.log *.tmp -*.bak -windows/*.report.json -# IDE -.idea/ +# Environment +.env +.env.local +*.env.* + +# Editors .vscode/ +.idea/ +# Python __pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ +pip-log.txt +pip-delete-this-directory.txt + +# Build directories +dist/ +build/ +*.egg-info/ + +# Coverage +.coverage +coverage/ +htmlcov/ +.nyc_output/ + +# Testing +.pytest_cache/ +.mypy_cache/ +.tox/ +.hypothesis/ + +# OS generated files +.DS_Store +Thumbs.db +``` \ No newline at end of file diff --git a/i18n/README_RU.md b/i18n/README_RU.md new file mode 100644 index 0000000..9418a0e --- /dev/null +++ b/i18n/README_RU.md @@ -0,0 +1,264 @@ +# Интернационализация (i18n) в AWatch-rus + +## Обзор + +Модуль интернационализации обеспечивает поддержку многоязычного интерфейса для PowerShell-скриптов проекта AWatch-rus. + +## Возможности + +- **JSON-based каталоги сообщений** — удобное хранение и редактирование переводов +- **Автоматический fallback** — при отсутствии перевода используется резервный язык +- **Параметризованные сообщения** — поддержка форматирования с плейсхолдерами `{0}`, `{1}`, etc. +- **Автоверсионизация** — семантическое версионирование каталогов переводов +- **Валидация** — проверка структуры и консистентности переводов +- **Анализ покрытия** — отчет о полноте переводов по языкам + +## Структура + +``` +i18n/ +├── en-US.json # Reference locale (English) +├── ru-RU.json # Russian translation +├── package.json # Node.js scripts configuration +└── scripts/ + ├── validate-locales.js # Валидация JSON-файлов + ├── check-coverage.js # Анализ покрытия переводов + └── bump-version.js # Автоверсионирование +``` + +## Быстрый старт + +### 1. Инициализация локали в PowerShell скрипте + +```powershell +# Импортируйте модуль i18n +Import-Module "$PSScriptRoot\ActivityWatch.Windows.I18n.psm1" -Force + +# Инициализируйте русскую локаль с английским fallback +Initialize-Locale -Culture "ru-RU" -FallbackCulture "en-US" + +# Или автодетект культуры системы +Initialize-Locale -AutoDetect +``` + +### 2. Использование локализованных строк + +```powershell +# Простое сообщение +$message = Get-LocalizedString -Key "errors.admin_required" + +# Сообщение с параметрами +$message = Get-LocalizedString -Key "errors.binary_missing" -FormatArgs @("aw-watcher-afk.exe") + +# Вывод информации +Get-LocalizedInfo -Key "starting_deployment" -FormatArgs @("v0.13.2") + +# Вывод предупреждения +Get-LocalizedWarning -Key "insecure_connection" + +# Создание ошибки +$errorRecord = Get-LocalizedError -Key "config_not_found" -FormatArgs @($configPath) +throw $errorRecord + +# Статусы +$status = Get-LocalizedStatus -Key "installation_success" + +# Подтверждение от пользователя +if (Read-LocalizedConfirm -PromptKey "confirm_install" -FormatArgs @($userCount)) { + # Продолжить установку +} +``` + +### 3. Категории сообщений + +| Категория | Префикс ключа | Пример использования | +|-----------|---------------|---------------------| +| Errors | `errors.*` | Сообщения об ошибках, исключения | +| Info | `info.*` | Информационные сообщения | +| Warnings | `warnings.*` | Предупреждения | +| Prompts | `prompts.*` | Запросы к пользователю | +| Status | `status.*` | Статусы операций | +| Choices | `choices.*` | Варианты выбора | + +## Формат JSON каталога + +```json +{ + "version": "1.0.0", + "language": "ru", + "fallback": "en", + "messages": { + "errors.admin_required": "Запустите этот скрипт из сеанса PowerShell с правами администратора.", + "errors.binary_missing": "Отсутствует требуемый двоичный файл ActivityWatch: {0}", + "info.starting_deployment": "Начало развертывания ActivityWatch версии {0}...", + "prompts.confirm_install": "Вы уверены, что хотите установить ActivityWatch для {0} пользователей?" + } +} +``` + +### Поля каталога + +| Поле | Тип | Обязательное | Описание | +|------|-----|--------------|----------| +| `version` | string | Да | Семантическая версия (X.Y.Z) | +| `language` | string | Да | Код языка (например, "ru", "en") | +| `fallback` | string/null | Да | Код резервного языка или null | +| `messages` | object | Да | Объект с сообщениями | + +## Скрипты управления + +### Валидация переводов + +Проверяет структуру JSON, наличие обязательных полей, консистентность плейсхолдеров: + +```bash +cd i18n +npm install +npm run validate +``` + +### Анализ покрытия + +Сравнивает все локали с reference (en-US) и показывает процент покрытия: + +```bash +npm run coverage +``` + +Пример вывода: +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Locale │ Version │ Language │ Coverage │ Missing │ Extra │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ ru-RU │ 1.0.0 │ ru │ 100.00% ✅ │ 0 │ 0 │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +### Автоверсионирование + +Бump версии всех каталогов одновременно: + +```bash +# Patch bump (1.0.0 → 1.0.1) +npm run bump + +# Minor bump (1.0.0 → 1.1.0) +npm run bump -- --minor + +# Major bump (1.0.0 → 2.0.0) +npm run bump -- --major +``` + +## Интеграция с существующими скриптами + +### Обновление ActivityWatch.Windows.Common.psm1 + +Замените хардкодные строки на вызовы i18n: + +**До:** +```powershell +throw 'Run this script from an elevated PowerShell session.' +``` + +**После:** +```powershell +throw (Get-LocalizedString -Key "errors.admin_required") +``` + +### Обновление deploy-ensemble.ps1 + +**До:** +```powershell +Write-Host "Starting ActivityWatch deployment..." -ForegroundColor Cyan +``` + +**После:** +```powershell +Get-LocalizedInfo -Key "starting_deployment" +``` + +## Добавление нового языка + +1. Скопируйте `en-US.json` как шаблон: + ```bash + cp i18n/en-US.json i18n/fr-FR.json + ``` + +2. Отредактируйте `fr-FR.json`: + - Измените `language` на `"fr"` + - Установите `fallback` в `"en"` + - Переведите все сообщения в `messages` + +3. Проверьте валидность: + ```bash + npm run validate fr-FR.json + ``` + +4. Протестируйте в PowerShell: + ```powershell + Initialize-Locale -Culture "fr-FR" + ``` + +## Best Practices + +### ✅ Делайте + +- Используйте семантическое версионирование для каталогов +- Всегда указывайте fallback для resilience +- Группируйте сообщения по категориям (errors.*, info.*, etc.) +- Нумеруйте плейсхолдеры последовательно: `{0}`, `{1}`, `{2}` +- Проверяйте покрытие перед релизом (`npm run coverage`) + +### ❌ Не делайте + +- Не хардкодьте строки в коде скриптов +- Не смешивайте языки в одном сообщении +- Не пропускайте плейсхолдеры в переводах +- Не забывайте обновлять версию при изменении сообщений + +## Troubleshooting + +### Ошибка: "Localization file not found" + +Убедитесь, что путь к i18n директории правильный: +```powershell +$env:I18N_ROOT = "C:\Path\To\i18n" +Initialize-Locale -Culture "ru-RU" -I18nRoot $env:I18N_ROOT +``` + +### Ошибка: "Failed to format message" + +Проверьте соответствие количества аргументов плейсхолдерам: +```powershell +# ❌ Неправильно: 2 аргумента для 1 плейсхолдера +Get-LocalizedString -Key "errors.binary_missing" -FormatArgs @("file.exe", "extra") + +# ✅ Правильно +Get-LocalizedString -Key "errors.binary_missing" -FormatArgs @("file.exe") +``` + +### Missing keys после обновления en-US + +Запустите анализ покрытия и добавьте отсутствующие ключи: +```bash +npm run coverage +# Отредактируйте ru-RU.json, добавив missing keys +npm run validate +``` + +## Миграция с хардкодных строк + +1. Экспортируйте существующие строки в шаблон: + ```powershell + Export-LocaleTemplate -OutputPath ".\i18n\template.json" + ``` + +2. Заполните переводы + +3. Постепенно заменяйте строки в коде на вызовы i18n функций + +4. Протестируйте с обоими локалями + +## Лицензия + +MIT diff --git a/i18n/en-US.json b/i18n/en-US.json new file mode 100644 index 0000000..a85711e --- /dev/null +++ b/i18n/en-US.json @@ -0,0 +1,62 @@ +{ + "version": "1.1.2", + "language": "en", + "fallback": null, + "messages": { + "errors.admin_required": "Run this script from an elevated PowerShell session.", + "errors.package_not_found": "Failed to find ActivityWatch executable: {0}", + "errors.binary_missing": "Missing required ActivityWatch binary: {0}", + "errors.no_users_resolved": "No target users resolved. Provide -Users or -UserListPath.", + "errors.config_not_found": "Deployment config not found: {0}", + "errors.schtasks_failed": "schtasks.exe /Change failed for task {0}", + "errors.icacls_failed": "icacls failed for {0}", + "errors.directory_create_failed": "Failed to create directory: {0}", + "errors.archive_extract_failed": "Failed to extract archive: {0}", + "errors.download_failed": "Failed to download file: {0}", + "errors.process_not_found": "Process not found: {0}", + "errors.bucket_create_failed": "Failed to create ActivityWatch bucket: {0}", + "errors.heartbeat_failed": "Failed to send heartbeat: {0}", + "errors.task_schedule_failed": "Failed to schedule task: {0}", + "errors.permission_denied": "Permission denied: {0}", + "errors.invalid_parameter": "Invalid parameter: {0} = {1}", + "errors.validation_failed": "Validation failed: {0}", + "info.starting_deployment": "Starting ActivityWatch deployment...", + "info.deployment_complete": "ActivityWatch deployment completed successfully.", + "info.installing_package": "Installing ActivityWatch package version {0}...", + "info.creating_directories": "Creating required directories...", + "info.configuring_users": "Configuring users: {0}", + "info.creating_scheduled_tasks": "Creating scheduled tasks...", + "info.setting_permissions": "Setting permissions...", + "info.validating_installation": "Validating installation...", + "info.backup_created": "Backup created: {0}", + "info.task_created": "Task created: {0}", + "info.task_updated": "Task updated: {0}", + "info.collector_started": "Collector started: {0}", + "info.bucket_created": "Bucket created: {0}", + "info.heartbeat_sent": "Heartbeat sent to {0}", + "info.configuration_written": "Configuration written: {0}", + "info.artifacts_copied": "Artifacts copied to {0}", + "info.cleaning_up": "Cleaning up temporary files...", + "info.version_check": "Version check: current {0}, required {1}", + "warnings.deprecated_parameter": "Parameter is deprecated and will be removed in a future version: {0}", + "warnings.config_overwrite": "Existing configuration file will be overwritten: {0}", + "warnings.insecure_connection": "Using insecure connection (TLS disabled)", + "warnings.low_disk_space": "Low disk space: {0} MB available, {1} MB required", + "warnings.slow_response": "Slow response from server: {0} ms", + "warnings.retry_attempt": "Retry attempt {0} of {1}...", + "prompts.confirm_install": "Are you sure you want to install ActivityWatch for {0} users?", + "prompts.confirm_overwrite": "Overwrite existing configuration file {0}?", + "prompts.enter_server_host": "Enter ActivityWatch server host:", + "prompts.enter_server_port": "Enter ActivityWatch server port (default 5600):", + "prompts.select_install_mode": "Select installation mode: [1] Single user, [2] All domain users", + "status.installation_in_progress": "Installation in progress...", + "status.installation_success": "Installation completed successfully", + "status.installation_failed": "Installation failed", + "status.configuration_pending": "Configuration pending", + "status.configuration_applied": "Configuration applied", + "status.service_running": "Service running", + "status.service_stopped": "Service stopped", + "status.collectors_active": "Collectors active", + "status.collectors_inactive": "Collectors inactive" + } +} \ No newline at end of file diff --git a/i18n/package.json b/i18n/package.json new file mode 100644 index 0000000..88b77a2 --- /dev/null +++ b/i18n/package.json @@ -0,0 +1,28 @@ +{ + "name": "awatch-rus-i18n", + "version": "1.1.2", + "description": "Internationalization (i18n) module for AWatch-rus project", + "type": "module", + "scripts": { + "validate": "node scripts/validate-locales.js", + "coverage": "node scripts/check-coverage.js", + "extract": "node scripts/extract-keys.js", + "bump": "node scripts/bump-version.js" + }, + "keywords": [ + "i18n", + "localization", + "l10n", + "russian", + "activitywatch" + ], + "author": "AWatch-rus Team", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "devDependencies": { + "glob": "^10.3.0", + "jsonschema": "^1.4.1" + } +} \ No newline at end of file diff --git a/i18n/ru-RU.json b/i18n/ru-RU.json new file mode 100644 index 0000000..cc104ea --- /dev/null +++ b/i18n/ru-RU.json @@ -0,0 +1,62 @@ +{ + "version": "1.1.2", + "language": "ru", + "fallback": "en", + "messages": { + "errors.admin_required": "Запустите этот скрипт из сеанса PowerShell с правами администратора.", + "errors.package_not_found": "Не удалось найти исполняемый файл ActivityWatch: {0}", + "errors.binary_missing": "Отсутствует требуемый двоичный файл ActivityWatch: {0}", + "errors.no_users_resolved": "Не определено целевых пользователей. Укажите -Users или -UserListPath.", + "errors.config_not_found": "Файл конфигурации развертывания не найден: {0}", + "errors.schtasks_failed": "Команда schtasks.exe /Change не выполнена для задачи {0}", + "errors.icacls_failed": "Команда icacls не выполнена для {0}", + "errors.directory_create_failed": "Не удалось создать каталог: {0}", + "errors.archive_extract_failed": "Не удалось распаковать архив: {0}", + "errors.download_failed": "Не удалось загрузить файл: {0}", + "errors.process_not_found": "Процесс не найден: {0}", + "errors.bucket_create_failed": "Не удалось создать бакет ActivityWatch: {0}", + "errors.heartbeat_failed": "Не удалось отправить heartbeat: {0}", + "errors.task_schedule_failed": "Не удалось запланировать задачу: {0}", + "errors.permission_denied": "Отказано в доступе: {0}", + "errors.invalid_parameter": "Недопустимый параметр: {0} = {1}", + "errors.validation_failed": "Проверка не пройдена: {0}", + "info.starting_deployment": "Начало развертывания ActivityWatch...", + "info.deployment_complete": "Развертывание ActivityWatch завершено успешно.", + "info.installing_package": "Установка пакета ActivityWatch версии {0}...", + "info.creating_directories": "Создание необходимых каталогов...", + "info.configuring_users": "Настройка пользователей: {0}", + "info.creating_scheduled_tasks": "Создание запланированных задач...", + "info.setting_permissions": "Настройка прав доступа...", + "info.validating_installation": "Проверка установки...", + "info.backup_created": "Резервная копия создана: {0}", + "info.task_created": "Задача создана: {0}", + "info.task_updated": "Задача обновлена: {0}", + "info.collector_started": "Сборщик запущен: {0}", + "info.bucket_created": "Бакет создан: {0}", + "info.heartbeat_sent": "Heartbeat отправлен в {0}", + "info.configuration_written": "Конфигурация записана: {0}", + "info.artifacts_copied": "Артефакты скопированы в {0}", + "info.cleaning_up": "Очистка временных файлов...", + "info.version_check": "Проверка версии: текущая {0}, требуется {1}", + "warnings.deprecated_parameter": "Параметр устарел и будет удален в будущей версии: {0}", + "warnings.config_overwrite": "Существующая конфигурация будет перезаписана: {0}", + "warnings.insecure_connection": "Используется небезопасное соединение (TLS отключен)", + "warnings.low_disk_space": "Недостаточно места на диске: доступно {0} МБ, требуется {1} МБ", + "warnings.slow_response": "Медленный ответ от сервера: {0} мс", + "warnings.retry_attempt": "Попытка повторения {0} из {1}...", + "prompts.confirm_install": "Вы уверены, что хотите установить ActivityWatch для {0} пользователей?", + "prompts.confirm_overwrite": "Перезаписать существующий файл конфигурации {0}?", + "prompts.enter_server_host": "Введите адрес сервера ActivityWatch:", + "prompts.enter_server_port": "Введите порт сервера ActivityWatch (по умолчанию 5600):", + "prompts.select_install_mode": "Выберите режим установки: [1] Один пользователь, [2] Все пользователи домена", + "status.installation_in_progress": "Идет установка...", + "status.installation_success": "Установка завершена успешно", + "status.installation_failed": "Ошибка установки", + "status.configuration_pending": "Конфигурация ожидается", + "status.configuration_applied": "Конфигурация применена", + "status.service_running": "Служба работает", + "status.service_stopped": "Служба остановлена", + "status.collectors_active": "Сборщики активны", + "status.collectors_inactive": "Сборщики не активны" + } +} \ No newline at end of file diff --git a/i18n/scripts/bump-version.js b/i18n/scripts/bump-version.js new file mode 100644 index 0000000..0cb22fa --- /dev/null +++ b/i18n/scripts/bump-version.js @@ -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(); diff --git a/i18n/scripts/check-coverage.js b/i18n/scripts/check-coverage.js new file mode 100644 index 0000000..6757c58 --- /dev/null +++ b/i18n/scripts/check-coverage.js @@ -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(); diff --git a/i18n/scripts/validate-locales.js b/i18n/scripts/validate-locales.js new file mode 100644 index 0000000..3c6a221 --- /dev/null +++ b/i18n/scripts/validate-locales.js @@ -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(); diff --git a/windows/ActivityWatch.Windows.I18n.psm1 b/windows/ActivityWatch.Windows.I18n.psm1 new file mode 100644 index 0000000..9495ea8 --- /dev/null +++ b/windows/ActivityWatch.Windows.I18n.psm1 @@ -0,0 +1,403 @@ +<# +.SYNOPSIS + PowerShell i18n module with JSON-based localization and fallback mechanism. +.DESCRIPTION + Provides internationalization support for PowerShell scripts with: + - JSON-based message catalogs + - Automatic fallback to default language + - Parameterized messages with format placeholders + - Auto-versioning support +.EXAMPLE + Import-Module ./ActivityWatch.Windows.I18n.psm1 + Initialize-Locale -Culture "ru-RU" + Get-LocalizedString -Key "errors.admin_required" +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Module state +$Script:I18nState = @{ + CurrentCulture = 'en-US' + FallbackCulture = 'en-US' + Messages = @{} + FallbackMessages = @{} + I18nRoot = $PSScriptRoot + '\..\i18n' +} + +function Get-I18nFilePath { + param( + [Parameter(Mandatory = $true)] + [string]$Culture, + [string]$I18nRoot = $Script:I18nState.I18nRoot + ) + + $fileName = "{0}.json" -f $Culture + return Join-Path -Path $I18nRoot -ChildPath $fileName +} + +function Test-I18nFileExists { + param( + [Parameter(Mandatory = $true)] + [string]$Culture, + [string]$I18nRoot = $Script:I18nState.I18nRoot + ) + + $filePath = Get-I18nFilePath -Culture $Culture -I18nRoot $I18nRoot + return Test-Path -LiteralPath $filePath +} + +function Load-MessagesForCulture { + param( + [Parameter(Mandatory = $true)] + [string]$Culture, + [string]$I18nRoot = $Script:I18nState.I18nRoot + ) + + $filePath = Get-I18nFilePath -Culture $Culture -I18nRoot $I18nRoot + + if (-not (Test-Path -LiteralPath $filePath)) { + throw "Localization file not found: $filePath" + } + + $content = Get-Content -LiteralPath $filePath -Raw -Encoding UTF8 + $catalog = $content | ConvertFrom-Json + + return @{ + Version = $catalog.version + Language = $catalog.language + Fallback = $catalog.fallback + Messages = $catalog.messages + } +} + +function Initialize-Locale { + param( + [string]$Culture = 'ru-RU', + [string]$FallbackCulture = 'en-US', + [string]$I18nRoot = $Script:I18nState.I18nRoot, + [switch]$AutoDetect + ) + + if ($AutoDetect) { + $Culture = (Get-Culture).Name + Write-Host "Auto-detected culture: $Culture" -ForegroundColor Cyan + } + + $Script:I18nState.CurrentCulture = $Culture + $Script:I18nState.FallbackCulture = $FallbackCulture + + try { + $primaryCatalog = Load-MessagesForCulture -Culture $Culture -I18nRoot $I18nRoot + $Script:I18nState.Messages = $primaryCatalog.Messages + + if ($primaryCatalog.Fallback) { + $fallbackCatalog = Load-MessagesForCulture -Culture $primaryCatalog.Fallback -I18nRoot $I18nRoot + $Script:I18nState.FallbackMessages = $fallbackCatalog.Messages + } + + Write-Host "Locale initialized: $Culture (fallback: $($primaryCatalog.Fallback ?? $FallbackCulture))" -ForegroundColor Green + return $true + } + catch { + Write-Warning "Failed to load primary locale '$Culture'. Attempting fallback..." + + try { + $fallbackCatalog = Load-MessagesForCulture -Culture $FallbackCulture -I18nRoot $I18nRoot + $Script:I18nState.Messages = @{} + $Script:I18nState.FallbackMessages = $fallbackCatalog.Messages + Write-Host "Using fallback locale only: $FallbackCulture" -ForegroundColor Yellow + return $true + } + catch { + Write-Error "Failed to load both primary and fallback locales." + return $false + } + } +} + +function Get-LocalizedString { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [object[]]$FormatArgs = @(), + [string]$DefaultValue + ) + + $message = $null + + if ($Script:I18nState.Messages.ContainsKey($Key)) { + $message = $Script:I18nState.Messages[$Key] + } + elseif ($Script:I18nState.FallbackMessages.ContainsKey($Key)) { + $message = $Script:I18nState.FallbackMessages[$Key] + } + elseif ($DefaultValue) { + $message = $DefaultValue + } + else { + $message = "[MISSING: $Key]" + } + + if ($FormatArgs -and $FormatArgs.Count -gt 0) { + try { + $message = [string]::Format($message, $FormatArgs) + } + catch { + Write-Warning "Failed to format message '$Key' with args: $($FormatArgs -join ', ')" + } + } + + return $message +} + +function Get-LocalizedError { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [object[]]$FormatArgs = @() + ) + + $message = Get-LocalizedString -Key "errors.$Key" -FormatArgs $FormatArgs + return New-Object System.Management.Automation.ErrorRecord( + (New-Object Exception($message)), + $Key, + [System.Management.Automation.ErrorCategory]::OperationStopped, + $null + ) +} + +function Get-LocalizedWarning { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [object[]]$FormatArgs = @() + ) + + $message = Get-LocalizedString -Key "warnings.$Key" -FormatArgs $FormatArgs + Write-Warning -Message $message +} + +function Get-LocalizedInfo { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [object[]]$FormatArgs = @(), + [ConsoleColor]$Color = 'Cyan' + ) + + $message = Get-LocalizedString -Key "info.$Key" -FormatArgs $FormatArgs + Write-Host -Message $message -ForegroundColor $Color +} + +function Get-LocalizedStatus { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [object[]]$FormatArgs = @() + ) + + return Get-LocalizedString -Key "status.$Key" -FormatArgs $FormatArgs +} + +function Get-LocalizedPrompt { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [object[]]$FormatArgs = @() + ) + + return Get-LocalizedString -Key "prompts.$Key" -FormatArgs $FormatArgs +} + +function Read-LocalizedChoice { + param( + [Parameter(Mandatory = $true)] + [string]$PromptKey, + [string[]]$Choices, + [object[]]$FormatArgs = @(), + [int]$DefaultChoice = 0 + ) + + $promptMessage = Get-LocalizedPrompt -Key $PromptKey -FormatArgs $FormatArgs + $choiceMessages = $Choices | ForEach-Object { + Get-LocalizedString -Key "choices.$_" + } + + $formattedChoices = for ($i = 0; $i -lt $Choices.Count; $i++) { + "[{0}] {1}" -f ($i + 1), $choiceMessages[$i] + } + + $fullPrompt = "{0}`n{1}" -f $promptMessage, ($formattedChoices -join "`n") + + $result = Read-Host -Prompt $fullPrompt + + if ([string]::IsNullOrWhiteSpace($result)) { + return $DefaultChoice + } + + $selectedIndex = 0 + if ([int]::TryParse($result, [ref]$selectedIndex) -and $selectedIndex -gt 0 -and $selectedIndex -le $Choices.Count) { + return $selectedIndex - 1 + } + + return $DefaultChoice +} + +function Read-LocalizedConfirm { + param( + [Parameter(Mandatory = $true)] + [string]$PromptKey, + [object[]]$FormatArgs = @(), + [switch]$Force + ) + + if ($Force) { + return $true + } + + $promptMessage = Get-LocalizedPrompt -Key $PromptKey -FormatArgs $FormatArgs + $yesMessage = Get-LocalizedString -Key "choices.yes" -DefaultValue "Yes" + $noMessage = Get-LocalizedString -Key "choices.no" -DefaultValue "No" + + $result = Read-Host -Prompt "$promptMessage ($yesMessage/$noMessage)" + + return $result -in @('y', 'Y', 'yes', 'Yes', $yesMessage) +} + +function Get-AvailableLocales { + param( + [string]$I18nRoot = $Script:I18nState.I18nRoot + ) + + if (-not (Test-Path -LiteralPath $I18nRoot)) { + return @() + } + + $locales = Get-ChildItem -Path $I18nRoot -Filter "*.json" -File | ForEach-Object { + $culture = $_.BaseName + try { + $catalog = Load-MessagesForCulture -Culture $culture -I18nRoot $I18nRoot + [PSCustomObject]@{ + Culture = $culture + Language = $catalog.Language + Version = $catalog.Version + HasFallback = [bool]$catalog.Fallback + } + } + catch { + Write-Warning "Failed to load locale $culture : $_" + } + } + + return $locales +} + +function Get-I18nVersion { + param( + [string]$Culture = $Script:I18nState.CurrentCulture, + [string]$I18nRoot = $Script:I18nState.I18nRoot + ) + + try { + $catalog = Load-MessagesForCulture -Culture $Culture -I18nRoot $I18nRoot + return $catalog.Version + } + catch { + return $null + } +} + +function Test-I18nUpdateAvailable { + param( + [string]$CurrentVersion, + [string]$Culture = $Script:I18nState.CurrentCulture, + [string]$I18nRoot = $Script:I18nState.I18nRoot + ) + + $availableVersion = Get-I18nVersion -Culture $Culture -I18nRoot $I18nRoot + + if (-not $CurrentVersion -or -not $availableVersion) { + return $false + } + + try { + $current = [Version]$CurrentVersion + $available = [Version]$availableVersion + return $available -gt $current + } + catch { + return $false + } +} + +function Export-LocaleTemplate { + param( + [Parameter(Mandatory = $true)] + [string]$OutputPath, + [string]$SourceCulture = 'en-US' + ) + + $catalog = Load-MessagesForCulture -Culture $SourceCulture + + $template = [PSCustomObject]@{ + version = "1.0.0" + language = $catalog.Language + fallback = $null + messages = $catalog.Messages + } + + $directory = Split-Path -Path $OutputPath -Parent + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -Path $directory -ItemType Directory -Force | Out-Null + } + + $template | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 + Write-Host "Locale template exported to: $OutputPath" -ForegroundColor Green +} + +function Compare-Locales { + param( + [string]$Culture1 = 'en-US', + [string]$Culture2 = 'ru-RU', + [string]$I18nRoot = $Script:I18nState.I18nRoot + ) + + $catalog1 = Load-MessagesForCulture -Culture $Culture1 -I18nRoot $I18nRoot + $catalog2 = Load-MessagesForCulture -Culture $Culture2 -I18nRoot $I18nRoot + + $keys1 = $catalog1.Messages.Keys + $keys2 = $catalog2.Messages.Keys + + $missing = $keys1 | Where-Object { $_ -notin $keys2 } + $extra = $keys2 | Where-Object { $_ -notin $keys1 } + + return [PSCustomObject]@{ + Culture1 = $Culture1 + Culture2 = $Culture2 + KeysInCulture1 = $keys1.Count + KeysInCulture2 = $keys2.Count + MissingInCulture2 = @($missing) + ExtraInCulture2 = @($extra) + CoveragePercent = if ($keys1.Count -gt 0) { + [math]::Round((($keys1.Count - $missing.Count) / $keys1.Count) * 100, 2) + } else { 0 } + } +} + +Export-ModuleMember -Function @( + 'Initialize-Locale', + 'Get-LocalizedString', + 'Get-LocalizedError', + 'Get-LocalizedWarning', + 'Get-LocalizedInfo', + 'Get-LocalizedStatus', + 'Get-LocalizedPrompt', + 'Read-LocalizedChoice', + 'Read-LocalizedConfirm', + 'Get-AvailableLocales', + 'Get-I18nVersion', + 'Test-I18nUpdateAvailable', + 'Export-LocaleTemplate', + 'Compare-Locales' +)