mirror of
https://github.com/Raphire/Win11Debloat.git
synced 2026-10-07 22:16:43 +00:00
* Translate app names and descriptions in Config/Apps.json * Document Apps section and first-ID translation key for multi-ID apps * Fix app list breaking when "Only show installed" runs translation in an isolated runspace
428 lines
16 KiB
PowerShell
428 lines
16 KiB
PowerShell
<#
|
|
.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
|
|
}
|