Files
Win11Debloat/Scripts/FileIO/Import-LanguageFile.ps1
T

428 lines
16 KiB
PowerShell
Raw Normal View History

<#
.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
$chrome = Import-JsonFile -filePath (Join-Path $folderPath 'Chrome.json')
$features = Import-JsonFile -filePath (Join-Path $folderPath 'Features.json')
$categories = Import-JsonFile -filePath (Join-Path $folderPath 'Categories.json')
$apps = Import-JsonFile -filePath (Join-Path $folderPath 'Apps.json')
if (-not $chrome -or -not $features -or -not $categories -or -not $apps) {
return $null
}
return [PSCustomObject]@{
LanguageCode = $LanguageFolder
Chrome = $chrome
Features = $features.Features
UiGroups = $features.UiGroups
Categories = $categories
Apps = $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) {
# 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
Substitutes %LANG:Key% markers in XAML text with translated, XML-escaped values.
.DESCRIPTION
Only resolves flat Chrome.json keys, since XAML markers never reference a Feature/Category/
UiGroup field directly (those get their text from Get-Translation calls in the GUI scripts
that build dynamic controls). Runs a single pass over every marker rather than sequential
.Replace() calls, so an already-substituted value can't be re-matched by a later key.
After substitution, scans for any %LANG:...% text that survived unresolved and throws,
since Get-Translation's key-as-fallback behavior means a missing key would otherwise render
as plain, un-marked text (e.g. "TitleBarClose" instead of a visible error) rather than being
caught here. Checks both the active language and its Fallback before flagging a key missing,
so a partially-translated language degrades to en-US text instead of failing to load.
#>
function ConvertTo-LocalizedXaml {
param(
[Parameter(Mandatory)]
[string]$Xaml,
[object]$Lang = $script:Lang
)
$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) }
$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
}