<# .SYNOPSIS Returns the required language catalog names. .OUTPUTS System.String. The required catalog names. #> function Get-LanguageCatalogNames { return @('Chrome', 'Features', 'Categories', 'Apps') } <# .SYNOPSIS Lists language folders containing every required JSON catalog. .PARAMETER LanguagesPath The directory containing language folders. Defaults to $script:LanguagesPath. .OUTPUTS System.IO.DirectoryInfo. The available language folders. #> function Get-AvailableLanguageFolders { param([string]$LanguagesPath = $script:LanguagesPath) $catalogNames = Get-LanguageCatalogNames foreach ($folder in (Get-ChildItem -LiteralPath $LanguagesPath -Directory | Sort-Object Name)) { $missingCatalogs = @($catalogNames | Where-Object { -not (Test-Path -LiteralPath (Join-Path $folder.FullName "$_.json") -PathType Leaf) }) if ($missingCatalogs.Count -eq 0) { $folder } } } <# .SYNOPSIS Resolves a language code to an available Config/Languages folder, or falls back to en-US. #> function Resolve-LanguageFolder { param( [Parameter(Mandatory)] [string]$LanguageCode, [string]$LanguagesPath = $script:LanguagesPath ) $exactPath = Join-Path $LanguagesPath $LanguageCode if (Test-Path $exactPath -PathType Container) { return $LanguageCode } $languagePrefix = ($LanguageCode -split '-')[0] $prefixMatch = Get-ChildItem -Path $LanguagesPath -Directory -Filter "$languagePrefix-*" -ErrorAction SilentlyContinue | Select-Object -First 1 if ($prefixMatch) { return $prefixMatch.Name } return 'en-US' } <# .SYNOPSIS Loads Chrome/Features/Categories/Apps JSON for a language folder, or returns $null on failure. #> function Import-LanguageContent { param( [Parameter(Mandatory)] [string]$LanguageFolder, [string]$LanguagesPath = $script:LanguagesPath ) $folderPath = Join-Path $LanguagesPath $LanguageFolder $catalogs = @{} foreach ($name in (Get-LanguageCatalogNames)) { $catalogs[$name] = Import-JsonFile -filePath (Join-Path $folderPath "$name.json") } if (@($catalogs.Values | Where-Object { -not $_ }).Count) { return $null } return [PSCustomObject]@{ LanguageCode = $LanguageFolder Chrome = $catalogs.Chrome Features = $catalogs.Features.Features UiGroups = $catalogs.Features.UiGroups Categories = $catalogs.Categories Apps = $catalogs.Apps } } <# .SYNOPSIS Loads the active language content, falling back to en-US when the requested language fails to load. #> function Import-LanguageFile { param( [string]$LanguageCode = ([System.Globalization.CultureInfo]::CurrentUICulture.Name), [string]$LanguagesPath = $script:LanguagesPath ) $resolvedFolder = Resolve-LanguageFolder -LanguageCode $LanguageCode -LanguagesPath $LanguagesPath $content = Import-LanguageContent -LanguageFolder $resolvedFolder -LanguagesPath $LanguagesPath if (-not $content -and $resolvedFolder -ne 'en-US') { Write-Warning "Failed to load language '$resolvedFolder', falling back to en-US." $content = Import-LanguageContent -LanguageFolder 'en-US' -LanguagesPath $LanguagesPath } if (-not $content) { Write-Error "Unable to load the en-US language files. The GUI cannot continue without them." return $null } if ($content.LanguageCode -ne 'en-US') { $content | Add-Member -MemberType NoteProperty -Name 'Fallback' -Value (Import-LanguageContent -LanguageFolder 'en-US' -LanguagesPath $LanguagesPath) } return $content } <# .SYNOPSIS Returns the CLDR plural category ('one', 'other', etc.) for a count in the given language. .DESCRIPTION Each language's CLDR plural rule is its own case, keyed by language prefix ('en' matches both 'en-US' and 'en-GB'). A language without a case falls back to the English rule, which is also correct for Dutch and German but wrong for languages with 3+ plural categories or a different singular/plural split. #> function Get-PluralCategory { param( [Parameter(Mandatory)] [string]$LanguageCode, [Parameter(Mandatory)] [int]$Count ) $languagePrefix = ($LanguageCode -split '-')[0].ToLowerInvariant() switch ($languagePrefix) { # Portuguese CLDR rule: 'one' for 0 and 1, 'many' for positive multiples # of 1,000,000, and 'other' for every remaining count. 'pt' { if ($Count -le 1) { return 'one' } if ($Count % 1000000 -eq 0) { return 'many' } return 'other' } # English CLDR rule: singular only for exactly 1, plural otherwise. Also correct for Dutch/German. default { if ($Count -eq 1) { return 'one' } return 'other' } } } <# .SYNOPSIS Returns a language and its Fallback (if any) as an ordered lookup chain, skipping nulls. #> function Get-LanguageFallbackChain { param( [object]$Lang ) return @($Lang, $Lang.Fallback) | Where-Object { $_ } } <# .SYNOPSIS Finds the named section object (Chrome, Features, UiGroups, Categories, or Apps) that owns a key. .DESCRIPTION A FeatureId, GroupId, and CategoryId aren't guaranteed to be disjoint (Config/Features.json can and does reuse the same string as both a GroupId and a FeatureId), so the default search order below is only a fallback for callers who don't know which section their key belongs to. Pass -Section to look in exactly one section instead of guessing from the order. #> function Find-TranslationSection { param( [Parameter(Mandatory)] [object]$Lang, [Parameter(Mandatory)] [string]$Key, [ValidateSet('', 'Chrome', 'Features', 'UiGroups', 'Categories', 'Apps')] [string]$Section = '' ) if ($Section) { $sectionObject = $Lang.$Section if ($sectionObject -and $sectionObject.PSObject.Properties[$Key]) { return $sectionObject } return $null } foreach ($sectionName in 'Chrome', 'Features', 'UiGroups', 'Categories', 'Apps') { $sectionObject = $Lang.$sectionName if ($sectionObject -and $sectionObject.PSObject.Properties[$Key]) { return $sectionObject } } return $null } <# .SYNOPSIS Looks up a translated value from the active language, falling back to en-US, then to the key itself. .DESCRIPTION Chrome.json keys are flat strings, so -Field is omitted for those. Features/UiGroups/Categories/Apps keys (FeatureId/GroupId/CategoryId/AppId) resolve to an object, so -Field picks the property on it (Label, ToolTip, ApplyText, UndoLabel, ApplyUndoText for Features/UiGroups/Categories; FriendlyName, Description for Apps). One generic lookup covers every section instead of a separate function per section. A FeatureId and a GroupId aren't guaranteed to be distinct strings, so pass -Section ('Features', 'UiGroups', 'Categories', or 'Apps') whenever the caller knows which one it means, rather than relying on Find-TranslationSection's search order to guess correctly. When -Count is supplied, tries the plural-suffixed key ("$Key`_$category") before the bare key, so callers don't need to add a plural variant for every string, only the ones that need one. .OUTPUTS System.String. The translated string, or the original Key if no language has it. #> function Get-Translation { param( [Parameter(Mandatory)] [string]$Key, [string]$Field = $null, [object]$Lang = $script:Lang, [Nullable[int]]$Count = $null, [object[]]$FormatArgs = $null, [ValidateSet('', 'Chrome', 'Features', 'UiGroups', 'Categories', 'Apps')] [string]$Section = '' ) if (-not $Lang) { return $Key } $lookupKeys = @($Key) if ($null -ne $Count) { $category = Get-PluralCategory -LanguageCode $Lang.LanguageCode -Count $Count $lookupKeys = @("${Key}_$category", $Key) } $resolved = $Key :langLoop foreach ($candidateLang in (Get-LanguageFallbackChain -Lang $Lang)) { foreach ($lookupKey in $lookupKeys) { $sectionObject = Find-TranslationSection -Lang $candidateLang -Key $lookupKey -Section $Section if (-not $sectionObject) { continue } $entry = $sectionObject.$lookupKey if ($Field) { if ($entry.PSObject.Properties[$Field]) { $resolved = [string]$entry.$Field break langLoop } continue } $resolved = [string]$entry break langLoop } } if ($null -ne $FormatArgs -and $FormatArgs.Count -gt 0) { try { return $resolved -f $FormatArgs } catch { Write-Warning "Translation '$Key' has a placeholder mismatch with its format arguments: $($_.Exception.Message)" return $resolved } } return $resolved } <# .SYNOPSIS Looks up a translated label for one value of a UiGroup, falling back to en-US, then to the raw label. .DESCRIPTION UiGroups[GroupId].Values in the language files is a FeatureId -> Label map, one level deeper than Get-Translation's -Field lookup handles, so this has its own small fallback walk instead of overloading Get-Translation's flat-field shape. #> function Get-GroupValueTranslation { param( [Parameter(Mandatory)] [string]$GroupId, [Parameter(Mandatory)] [string]$FeatureId, [Parameter(Mandatory)] [string]$FallbackLabel, [object]$Lang = $script:Lang ) if (-not $Lang) { return $FallbackLabel } foreach ($candidateLang in (Get-LanguageFallbackChain -Lang $Lang)) { if (-not $candidateLang.UiGroups) { continue } $group = $candidateLang.UiGroups.$GroupId if (-not $group -or -not $group.Values) { continue } $valueEntry = $group.Values.PSObject.Properties[$FeatureId] if ($valueEntry) { return [string]$valueEntry.Value } } return $FallbackLabel } <# .SYNOPSIS Flattens a loaded language object into a sorted set of dotted key paths. .DESCRIPTION Chrome.json keys are already flat and are used as-is. Features/UiGroups/Categories/Apps keys are one level deeper (EntryId -> {Field: value}), so each field becomes its own "Section.EntryId.Field" path (e.g. "Features.DisableTelemetry.Label"). UiGroups' nested Values map becomes "UiGroups.GroupId.Values.FeatureId". Used by Test-LanguageKeyCoverage to diff one language's key set against another's. #> function Get-LanguageKeyPaths { param( [Parameter(Mandatory)] [object]$Lang ) $paths = New-Object System.Collections.Generic.List[string] foreach ($chromeKey in @($Lang.Chrome.PSObject.Properties.Name)) { if ([string]::IsNullOrEmpty($chromeKey)) { continue } $paths.Add($chromeKey) } foreach ($sectionName in 'Features', 'Categories', 'Apps') { $section = $Lang.$sectionName if (-not $section) { continue } foreach ($entryId in @($section.PSObject.Properties.Name)) { if ([string]::IsNullOrEmpty($entryId)) { continue } foreach ($field in @($section.$entryId.PSObject.Properties.Name)) { if ([string]::IsNullOrEmpty($field)) { continue } $paths.Add("$sectionName.$entryId.$field") } } } foreach ($groupId in @($Lang.UiGroups.PSObject.Properties.Name)) { if ([string]::IsNullOrEmpty($groupId)) { continue } $group = $Lang.UiGroups.$groupId foreach ($field in @($group.PSObject.Properties.Name)) { if ([string]::IsNullOrEmpty($field)) { continue } if ($field -eq 'Values') { foreach ($featureId in @($group.Values.PSObject.Properties.Name)) { if ([string]::IsNullOrEmpty($featureId)) { continue } $paths.Add("UiGroups.$groupId.Values.$featureId") } continue } $paths.Add("UiGroups.$groupId.$field") } } return @($paths | Sort-Object -Unique) } <# .SYNOPSIS Diffs a language's key set against a baseline language, reporting missing and extra keys. .DESCRIPTION Loads both languages fresh from disk (independent of any already-cached $script:Lang), flattens each with Get-LanguageKeyPaths, and compares the two sets. Intended to run against en-US as the baseline; useful today (catches a key present in one en-US file but not another) and becomes the real translation-completeness check once non-English language folders are contributed. .OUTPUTS PSCustomObject with ResolvedLanguageCode (the folder actually compared, after the same exact/prefix/en-US resolution Import-LanguageFile applies), MissingKeys (present in Baseline, absent from LanguageCode), and ExtraKeys (present in LanguageCode, absent from Baseline) properties, the latter two sorted arrays. #> function Test-LanguageKeyCoverage { param( [Parameter(Mandatory)] [string]$LanguageCode, [string]$BaselineLanguageCode = 'en-US', [string]$LanguagesPath = $script:LanguagesPath ) $resolvedLanguageCode = Resolve-LanguageFolder -LanguageCode $LanguageCode -LanguagesPath $LanguagesPath $baseline = Import-LanguageContent -LanguageFolder $BaselineLanguageCode -LanguagesPath $LanguagesPath $target = Import-LanguageContent -LanguageFolder $resolvedLanguageCode -LanguagesPath $LanguagesPath if (-not $baseline -or -not $target) { Write-Error "Unable to load language content for coverage comparison ('$BaselineLanguageCode' vs '$resolvedLanguageCode')." return $null } $baselineKeys = [System.Collections.Generic.HashSet[string]]::new([string[]](Get-LanguageKeyPaths -Lang $baseline)) $targetKeys = [System.Collections.Generic.HashSet[string]]::new([string[]](Get-LanguageKeyPaths -Lang $target)) $missingKeys = @($baselineKeys | Where-Object { -not $targetKeys.Contains($_) } | Sort-Object) $extraKeys = @($targetKeys | Where-Object { -not $baselineKeys.Contains($_) } | Sort-Object) return [PSCustomObject]@{ ResolvedLanguageCode = $resolvedLanguageCode MissingKeys = $missingKeys ExtraKeys = $extraKeys } } <# .SYNOPSIS Replaces %LANG:Key% markers with translated text or dynamic language resources. .DESCRIPTION Resolves Chrome catalog markers using the active language and its English fallback. Supports embedded text or dynamic resources for windows that allow language changes. Throws when a marker's key is missing from both languages. .PARAMETER Xaml The XAML text containing %LANG:Key% markers to replace. .PARAMETER Lang The loaded language object and its optional Fallback. Defaults to $script:Lang. .PARAMETER DynamicResources Uses DynamicResource references to Language_ resources for live language updates. The window must provide those resources. When omitted, embeds XML-escaped translated text. .OUTPUTS System.String. The XAML text with localization markers replaced. #> function ConvertTo-LocalizedXaml { param( [Parameter(Mandatory)] [string]$Xaml, [object]$Lang = $script:Lang, # For live windows, keep a resource reference instead of embedding the translated text. [switch]$DynamicResources ) $missingKeys = New-Object System.Collections.Generic.List[string] $evaluator = [System.Text.RegularExpressions.MatchEvaluator]{ param($match) $key = $match.Groups[1].Value $resolvesSomewhere = @(Get-LanguageFallbackChain -Lang $Lang) | Where-Object { $_.Chrome -and $_.Chrome.PSObject.Properties[$key] } if (-not $resolvesSomewhere) { $missingKeys.Add($key) } if ($DynamicResources) { return "{DynamicResource Language_$key}" } $value = if ($resolvesSomewhere) { Get-Translation -Key $key -Lang $Lang } else { $key } return [System.Security.SecurityElement]::Escape($value) } $result = [System.Text.RegularExpressions.Regex]::Replace($Xaml, '%LANG:([A-Za-z0-9_]+)%', $evaluator) if ($missingKeys.Count -gt 0) { throw "Unresolved localization marker(s) in XAML, key(s) not found in any language: $($missingKeys -join ', ')" } return $result }