From 8bf4fa22c3b580c3c1b72cf032fa970e0b03a0fc Mon Sep 17 00:00:00 2001 From: BiosSystem <63607038+BiosSystem@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:01:36 +0300 Subject: [PATCH] Localize the GUI chrome Second half of localization. Covers the 194 literal strings across the nine XAML schemas, which the first pass left untouched. Substitution happens on the markup before it is parsed rather than by walking the loaded window. A logical tree does not contain text that sits inside a Style or a template: MainWindow carries 113 literal strings but exposes only 99 as logical tree nodes, so a tree walk would have silently missed fourteen of them. Chrome entries are keyed by their English text. Keying by position broke the moment markup was reordered, and only 46 percent of the strings sit on an x:Name element, so keying by element name would have meant adding identifiers to 103 elements across nine files. A source-string key is stable under reordering, shows the translator the original, and resolves to itself when untranslated. Values are read XML-decoded and written back XML-encoded, so a translation containing an ampersand or an angle bracket cannot corrupt the markup. Attribute values that open with a brace are skipped because they are bindings rather than text. All nine load sites now call Get-LocalizedXaml instead of reading the schema directly, which keeps this to one integration point. Two things the extraction had to handle. Forty-one values are icon glyph character references rather than text and are excluded. Three phrases differ only in capitalization, which ConvertFrom-Json cannot represent as distinct properties, so they share one entry and resolve through case-insensitive property lookup. Verified by loading every schema through XamlReader with all 123 entries translated: all nine parse, 153 substitutions land, and the 192 bindings in MainWindow are untouched. A test asserts every translatable schema string has a catalogue entry, so a new string without one fails the build. --- Config/Languages/en-US/Chrome.json | 128 +++++++++++++++++++++++ Scripts/FileIO/LoadLanguageFile.ps1 | 74 +++++++++++++ Scripts/GUI/SetWindowThemeResources.ps1 | 2 +- Scripts/GUI/Show-AboutDialog.ps1 | 2 +- Scripts/GUI/Show-AppSelectionWindow.ps1 | 2 +- Scripts/GUI/Show-ApplyModal.ps1 | 2 +- Scripts/GUI/Show-Bubble.ps1 | 2 +- Scripts/GUI/Show-ConfigWindow.ps1 | 2 +- Scripts/GUI/Show-MainWindow.ps1 | 2 +- Scripts/GUI/Show-MessageBox.ps1 | 2 +- Scripts/GUI/Show-RestoreBackupDialog.ps1 | 2 +- Tests/Unit/Test-Localization.ps1 | 82 +++++++++++++++ WinSwift-Standalone.ps1 | 2 +- 13 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 Config/Languages/en-US/Chrome.json diff --git a/Config/Languages/en-US/Chrome.json b/Config/Languages/en-US/Chrome.json new file mode 100644 index 0000000..68239da --- /dev/null +++ b/Config/Languages/en-US/Chrome.json @@ -0,0 +1,128 @@ +{ + "Version": "1", + "Chrome": { + "About WinSwift": "About WinSwift", + "Version:": "Version:", + "Author:": "Author:", + "BiosSystem": "BiosSystem", + "Project:": "Project:", + "https://github.com/BiosSystem/WinSwift": "https://github.com/BiosSystem/WinSwift", + "WinSwift is a passion project that I maintain in my free time. If you've found this tool useful, please consider making a small donation to support its development. I really appreciate it!": "WinSwift is a passion project that I maintain in my free time. If you've found this tool useful, please consider making a small donation to support its development. I really appreciate it!", + "Support me on Ko-fi": "Support me on Ko-fi", + "Close": "Close", + "WinSwift Application Selection": "WinSwift Application Selection", + "Check apps that you wish to remove, uncheck apps that you wish to keep": "Check apps that you wish to remove, uncheck apps that you wish to keep", + "Check/Uncheck all": "Check/Uncheck all", + "Loading apps...": "Loading apps...", + "Only show installed apps": "Only show installed apps", + "Confirm": "Confirm", + "Cancel": "Cancel", + "Apply Changes": "Apply Changes", + "Applying Changes": "Applying Changes", + "Preparing...": "Preparing...", + "Step 0 of 0": "Step 0 of 0", + "Changes Applied": "Changes Applied", + "Please note that some changes will only take effect after a reboot. Thanks for using WinSwift!": "Please note that some changes will only take effect after a reboot. Thanks for using WinSwift!", + "A reboot is required for these changes to take effect:": "A reboot is required for these changes to take effect:", + "Support the creator": "Support the creator", + "View the selected changes here": "View the selected changes here", + "Select Settings to Import/Export": "Select Settings to Import/Export", + "OK": "OK", + "WinSwift": "WinSwift", + "Options": "Options", + "Import config": "Import config", + "Export config": "Export config", + "Restore backup": "Restore backup", + "Documentation": "Documentation", + "Report a bug": "Report a bug", + "Logs": "Logs", + "About": "About", + "Home": "Home", + "Welcome to WinSwift": "Welcome to WinSwift", + "Your clean Windows experience is just a few clicks away!": "Your clean Windows experience is just a few clicks away!", + "What user do you want to apply changes to?": "What user do you want to apply changes to?", + "Current User": "Current User", + "Other User": "Other User", + "Windows Default User (Sysprep)": "Windows Default User (Sysprep)", + "Enter username": "Enter username", + "Default Mode": "Default Mode", + "Quickly select the recommended settings": "Quickly select the recommended settings", + "Custom Setup": "Custom Setup", + "Manually select your preferred settings": "Manually select your preferred settings", + "App Removal": "App Removal", + "Select which apps you want to remove": "Select which apps you want to remove", + "Quick Select": "Quick Select", + "Clear all selected apps": "Clear all selected apps", + "Clear Selection": "Clear Selection", + "Default apps": "Default apps", + "Select the apps that are safe to remove for most users": "Select the apps that are safe to remove for most users", + "Previously selected apps": "Previously selected apps", + "Select the apps that were removed the last time WinSwift was run": "Select the apps that were removed the last time WinSwift was run", + "Only show applications that are currently installed on this system": "Only show applications that are currently installed on this system", + "Search apps...": "Search apps...", + "Name": "Name", + "Description": "Description", + "App ID": "App ID", + "Legend:": "Legend:", + "[Recommended] Safe to remove for most users": "[Recommended] Safe to remove for most users", + "Recommended": "Recommended", + "[Optional] Can be safely removed if you don't need this app": "[Optional] Can be safely removed if you don't need this app", + "Optional": "Optional", + "[Not Recommended] Only remove if you know what you are doing": "[Not Recommended] Only remove if you know what you are doing", + "Not Recommended": "Not Recommended", + "Tweaks": "Tweaks", + "System Tweaks": "System Tweaks", + "Select what changes you want to make, you can hover over settings for more information": "Select what changes you want to make, you can hover over settings for more information", + "Clear all selected tweaks": "Clear all selected tweaks", + "Default settings": "Default settings", + "Select the settings that are recommended for most people": "Select the settings that are recommended for most people", + "Previously selected settings": "Previously selected settings", + "Select the settings that were applied the last time WinSwift was run": "Select the settings that were applied the last time WinSwift was run", + "Privacy & suggested content": "Privacy & suggested content", + "Select all tweaks related to privacy & suggested content": "Select all tweaks related to privacy & suggested content", + "AI features": "AI features", + "Select all AI related tweaks": "Select all AI related tweaks", + "Detect applied tweaks": "Detect applied tweaks", + "Detect all tweaks currently applied for the current user": "Detect all tweaks currently applied for the current user", + "Search tweaks...": "Search tweaks...", + "Deployment Settings": "Deployment Settings", + "Configure how the selected changes will be applied to your system": "Configure how the selected changes will be applied to your system", + "Changes will be applied to": "Changes will be applied to", + "The currently logged-in user profile.": "The currently logged-in user profile.", + "Apps will be removed for": "Apps will be removed for", + "All users": "All users", + "Current user only": "Current user only", + "Target user only": "Target user only", + "Apps will be removed for all users and from the Windows image to prevent reinstallation for new users.": "Apps will be removed for all users and from the Windows image to prevent reinstallation for new users.", + "Create a system restore point (Recommended)": "Create a system restore point (Recommended)", + "Restart the Windows Explorer process to apply all changes immediately": "Restart the Windows Explorer process to apply all changes immediately", + "Review selected changes": "Review selected changes", + "Back": "Back", + "Next": "Next", + "MessageBox": "MessageBox", + "Message": "Message", + "Message content goes here": "Message content goes here", + "SelectType": "SelectType", + "Choose what changes you want to restore.": "Choose what changes you want to restore.", + "Restore Registry Backup": "Restore Registry Backup", + "Restore system registry configuration from a backup": "Restore system registry configuration from a backup", + "Restore Start Menu Backup": "Restore Start Menu Backup", + "Restore the Start Menu pinned apps layout from a backup": "Restore the Start Menu pinned apps layout from a backup", + "Registry": "Registry", + "This will restore any system registry changes made by WinSwift to the previous state. You can review the changes after selecting a backup file. Removed applications will need to be reinstalled manually.": "This will restore any system registry changes made by WinSwift to the previous state. You can review the changes after selecting a backup file. Removed applications will need to be reinstalled manually.", + "Warning: Only use backup files generated by WinSwift.": "Warning: Only use backup files generated by WinSwift.", + "File:": "File:", + "Not selected": "Not selected", + "Created:": "Created:", + "N/A": "N/A", + "Target:": "Target:", + "The following changes will be reverted:": "The following changes will be reverted:", + "This will restore the Start Menu pinned apps layout for the current user.": "This will restore the Start Menu pinned apps layout for the current user.", + "The following changes will be re-applied:": "The following changes will be re-applied:", + "The following changes won't be reverted:": "The following changes won't be reverted:", + "Visit the wiki for more information": "Visit the wiki for more information", + "StartMenu": "StartMenu", + "This will restore the Start Menu pinned apps layout for the selected user(s) using a backup that is automatically created by WinSwift. Manually created backups can also be used.": "This will restore the Start Menu pinned apps layout for the selected user(s) using a backup that is automatically created by WinSwift. Manually created backups can also be used.", + "Automatically find Start Menu backup": "Automatically find Start Menu backup" + } +} diff --git a/Scripts/FileIO/LoadLanguageFile.ps1 b/Scripts/FileIO/LoadLanguageFile.ps1 index 7a59b30..ea45626 100644 --- a/Scripts/FileIO/LoadLanguageFile.ps1 +++ b/Scripts/FileIO/LoadLanguageFile.ps1 @@ -62,6 +62,8 @@ function Import-LanguageContent { $folderPath = Join-Path $LanguagesPath $LanguageFolder $features = LoadJsonFile -filePath (Join-Path $folderPath 'Features.json') -optionalFile $categories = LoadJsonFile -filePath (Join-Path $folderPath 'Categories.json') -optionalFile + # Optional: a language may translate features without covering the GUI. + $chrome = LoadJsonFile -filePath (Join-Path $folderPath 'Chrome.json') -optionalFile if (-not $features -or -not $categories) { return $null @@ -71,6 +73,7 @@ function Import-LanguageContent { LanguageCode = $LanguageFolder Features = $features.Features Categories = $categories.Categories + Chrome = if ($chrome) { $chrome.Chrome } else { $null } Fallback = $null } } @@ -205,6 +208,77 @@ function Update-FeatureTextFromLanguage { return $replaced } +<# + .SYNOPSIS + Reads a XAML schema and returns it with its literal text translated. + + .DESCRIPTION + Substitution happens on the markup before it is parsed, rather than by + walking the loaded window. A logical tree does not contain text that lives + inside a Style or a template, so a tree walk reaches only part of the + markup: MainWindow alone carries 113 literal strings but exposes 99 as + logical tree nodes. + + Chrome entries are keyed by their English text, so reordering markup cannot + break them and an untranslated string resolves to itself. Values are read + XML-decoded and written back XML-encoded, so a translation containing an + ampersand or an angle bracket cannot corrupt the markup. + + Only Content, Text, Header, ToolTip and Title are considered, and a value + that opens with a brace is left alone because it is a binding or a resource + reference rather than text. + + .PARAMETER Path + The schema file to read. + + .OUTPUTS + System.String. The markup, translated where a translation exists. +#> +function Get-LocalizedXaml { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $markup = Get-Content -LiteralPath $Path -Raw + + $chrome = $null + foreach ($catalogue in @($script:Language, $script:Language.Fallback)) { + if ($catalogue -and $catalogue.Chrome) { $chrome = $catalogue.Chrome; break } + } + + if (-not $chrome) { + return $markup + } + + $evaluator = { + param($match) + + $attribute = $match.Groups['attr'].Value + $raw = $match.Groups['value'].Value + + # Bindings and resource references are not text. + if ($raw -match '^\s*\{') { return $match.Value } + + $decoded = [System.Net.WebUtility]::HtmlDecode($raw) + $entry = $chrome.PSObject.Properties[$decoded] + if (-not $entry) { return $match.Value } + + $translated = [string]$entry.Value + if ([string]::IsNullOrWhiteSpace($translated) -or $translated -eq $decoded) { + return $match.Value + } + + # Encode before it goes back into markup, and escape the double quote + # that would otherwise close the attribute. + $encoded = [System.Security.SecurityElement]::Escape($translated) + return ('{0}="{1}"' -f $attribute, $encoded) + } + + $pattern = '(?\b(?:Content|Text|Header|ToolTip|Title))="(?[^"]*)"' + return [regex]::Replace($markup, $pattern, $evaluator) +} + <# .SYNOPSIS Returns the localized display name for a feature category. diff --git a/Scripts/GUI/SetWindowThemeResources.ps1 b/Scripts/GUI/SetWindowThemeResources.ps1 index 610c017..1335e26 100644 --- a/Scripts/GUI/SetWindowThemeResources.ps1 +++ b/Scripts/GUI/SetWindowThemeResources.ps1 @@ -124,7 +124,7 @@ function SetWindowThemeResources { # Load and merge shared styles if ($script:SharedStylesSchema -and (Test-Path $script:SharedStylesSchema)) { - $sharedXaml = Get-Content -Path $script:SharedStylesSchema -Raw + $sharedXaml = Get-LocalizedXaml -Path $script:SharedStylesSchema $sharedReader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($sharedXaml)) try { $sharedDict = [System.Windows.Markup.XamlReader]::Load($sharedReader) diff --git a/Scripts/GUI/Show-AboutDialog.ps1 b/Scripts/GUI/Show-AboutDialog.ps1 index 18e8c94..5fbc02e 100644 --- a/Scripts/GUI/Show-AboutDialog.ps1 +++ b/Scripts/GUI/Show-AboutDialog.ps1 @@ -24,7 +24,7 @@ function Show-AboutDialog { } # Load XAML from file - $xaml = Get-Content -Path $script:AboutWindowSchema -Raw + $xaml = Get-LocalizedXaml -Path $script:AboutWindowSchema $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml)) try { $aboutWindow = [System.Windows.Markup.XamlReader]::Load($reader) diff --git a/Scripts/GUI/Show-AppSelectionWindow.ps1 b/Scripts/GUI/Show-AppSelectionWindow.ps1 index 01c3545..0320458 100644 --- a/Scripts/GUI/Show-AppSelectionWindow.ps1 +++ b/Scripts/GUI/Show-AppSelectionWindow.ps1 @@ -17,7 +17,7 @@ function Show-AppSelectionWindow { } # Load XAML from file - $xaml = Get-Content -Path $script:AppSelectionSchema -Raw + $xaml = Get-LocalizedXaml -Path $script:AppSelectionSchema $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml)) try { $window = [System.Windows.Markup.XamlReader]::Load($reader) diff --git a/Scripts/GUI/Show-ApplyModal.ps1 b/Scripts/GUI/Show-ApplyModal.ps1 index 70dbc59..a93c39b 100644 --- a/Scripts/GUI/Show-ApplyModal.ps1 +++ b/Scripts/GUI/Show-ApplyModal.ps1 @@ -26,7 +26,7 @@ function Show-ApplyModal { } # Load XAML from file - $xaml = Get-Content -Path $script:ApplyChangesWindowSchema -Raw + $xaml = Get-LocalizedXaml -Path $script:ApplyChangesWindowSchema $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml)) try { $applyWindow = [System.Windows.Markup.XamlReader]::Load($reader) diff --git a/Scripts/GUI/Show-Bubble.ps1 b/Scripts/GUI/Show-Bubble.ps1 index c5bcdcd..7886664 100644 --- a/Scripts/GUI/Show-Bubble.ps1 +++ b/Scripts/GUI/Show-Bubble.ps1 @@ -103,7 +103,7 @@ function Show-Bubble { Hide-Bubble -Immediate - $xaml = Get-Content -Path $script:BubbleHintSchema -Raw + $xaml = Get-LocalizedXaml -Path $script:BubbleHintSchema $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml)) try { $bubblePanel = [System.Windows.Markup.XamlReader]::Load($reader) diff --git a/Scripts/GUI/Show-ConfigWindow.ps1 b/Scripts/GUI/Show-ConfigWindow.ps1 index 767614e..30dc8d6 100644 --- a/Scripts/GUI/Show-ConfigWindow.ps1 +++ b/Scripts/GUI/Show-ConfigWindow.ps1 @@ -35,7 +35,7 @@ function Show-ImportExportConfigWindow { return $null } - $xaml = Get-Content -Path $schemaPath -Raw + $xaml = Get-LocalizedXaml -Path $schemaPath $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml)) try { $dlg = [System.Windows.Markup.XamlReader]::Load($reader) diff --git a/Scripts/GUI/Show-MainWindow.ps1 b/Scripts/GUI/Show-MainWindow.ps1 index 5aa8aa7..50d7f87 100644 --- a/Scripts/GUI/Show-MainWindow.ps1 +++ b/Scripts/GUI/Show-MainWindow.ps1 @@ -5,7 +5,7 @@ function Show-MainWindow { $usesDarkMode = GetSystemUsesDarkMode # ---- Load XAML ---- - $xaml = Get-Content -Path $script:MainWindowSchema -Raw + $xaml = Get-LocalizedXaml -Path $script:MainWindowSchema $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml)) try { $window = [System.Windows.Markup.XamlReader]::Load($reader) diff --git a/Scripts/GUI/Show-MessageBox.ps1 b/Scripts/GUI/Show-MessageBox.ps1 index 654758d..651a33c 100644 --- a/Scripts/GUI/Show-MessageBox.ps1 +++ b/Scripts/GUI/Show-MessageBox.ps1 @@ -46,7 +46,7 @@ function Show-MessageBox { } # Load XAML from file - $xaml = Get-Content -Path $script:MessageBoxSchema -Raw + $xaml = Get-LocalizedXaml -Path $script:MessageBoxSchema $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml)) try { $msgWindow = [System.Windows.Markup.XamlReader]::Load($reader) diff --git a/Scripts/GUI/Show-RestoreBackupDialog.ps1 b/Scripts/GUI/Show-RestoreBackupDialog.ps1 index 285db50..5f8ab10 100644 --- a/Scripts/GUI/Show-RestoreBackupDialog.ps1 +++ b/Scripts/GUI/Show-RestoreBackupDialog.ps1 @@ -49,7 +49,7 @@ function Show-RestoreBackupDialog { throw 'Restore backup window schema file could not be found.' } - $xaml = Get-Content -Path $schemaPath -Raw + $xaml = Get-LocalizedXaml -Path $schemaPath $reader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml)) try { diff --git a/Tests/Unit/Test-Localization.ps1 b/Tests/Unit/Test-Localization.ps1 index 7ebb808..c9a7de1 100644 --- a/Tests/Unit/Test-Localization.ps1 +++ b/Tests/Unit/Test-Localization.ps1 @@ -143,6 +143,61 @@ Describe 'WinSwift localization' { } } + Context 'XAML chrome substitution' { + + BeforeEach { + $script:Language = [PSCustomObject]@{ + LanguageCode = 'xx-XX' + Chrome = [PSCustomObject]@{ + 'Apply changes' = 'Aplicar cambios' + 'Risky & ' = 'Peligro & ' + } + Features = $null + Categories = $null + Fallback = $null + } + $script:sample = Join-Path $TestDrive 'sample.xaml' + } + + It 'substitutes a translated attribute' { + '