Files
AWatch-rus/windows/ActivityWatch.Windows.I18n.psm1
T
qwen.ai[bot] dc422e2e2e Title: Add i18n module with Russian localization and validation tools
Key features implemented:
- New i18n module with JSON-based message catalogs for multi-language support
- Added Russian (ru-RU) and English (en-US) localization files with 100+ messages
- PowerShell module ActivityWatch.Windows.I18n.psm1 with fallback mechanism
- Node.js validation and coverage analysis scripts for locale management
- Package.json configuration with semantic versioning and build tools
- README_RU.md documentation with integration examples and best practices

The implementation provides comprehensive internationalization support with automated validation, versioning, and fallback capabilities for robust multilingual deployments.
2026-04-27 06:55:20 +00:00

404 lines
11 KiB
PowerShell

<#
.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'
)