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:
+44
-7
@@ -1,14 +1,51 @@
|
|||||||
# Local secrets
|
```
|
||||||
secrets/deploy.secrets.env
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Runtime / reports
|
# Logs and temp files
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
|
||||||
windows/*.report.json
|
|
||||||
|
|
||||||
# IDE
|
# Environment
|
||||||
.idea/
|
.env
|
||||||
|
.env.local
|
||||||
|
*.env.*
|
||||||
|
|
||||||
|
# Editors
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Python
|
||||||
__pycache__/
|
__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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "Сборщики не активны"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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'
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user