Title: Add i18n module with Russian localization and validation tools
Key features implemented: - New i18n module with JSON-based message catalogs for multi-language support - Added Russian (ru-RU) and English (en-US) localization files with 100+ messages - PowerShell module ActivityWatch.Windows.I18n.psm1 with fallback mechanism - Node.js validation and coverage analysis scripts for locale management - Package.json configuration with semantic versioning and build tools - README_RU.md documentation with integration examples and best practices The implementation provides comprehensive internationalization support with automated validation, versioning, and fallback capabilities for robust multilingual deployments.
This commit is contained in:
@@ -0,0 +1,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