chore: import upstream snapshot with attribution
.NET Tests / test-codebase (push) Has been cancelled
Translation Validation / validate-translations (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:01:52 +08:00
commit 643e9f9fcb
1003 changed files with 247032 additions and 0 deletions
@@ -0,0 +1,218 @@
function Test-IntentionalSourceEqualValue {
param(
[Parameter(Mandatory = $true)]
[string]$LanguageCode,
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$SourceValue,
[Parameter(Mandatory = $false)]
[AllowEmptyString()]
[string]$TargetValue = ''
)
if ($SourceValue -in @(
'MSI',
'MSIX',
'OK',
'UniGetUI',
'UniGetUI - {0} {1}',
'WinGet COM API',
'your@email.com',
'{0}: {1}',
'{0}: {1}, {2}'
)) {
return $true
}
if ($LanguageCode -ceq 'it' -and $SourceValue -ceq 'No' -and $TargetValue -ceq 'No') {
return $true
}
if ($LanguageCode -ceq 'it' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'DEBUG BUILD'
)) {
return $true
}
if ($LanguageCode -ceq 'ca' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'1 - Errors',
'Error',
'Global',
'Local',
'Manifest',
'Manifests',
'No',
'Notes:',
'Text'
)) {
return $true
}
if ($LanguageCode -ceq 'pt_PT' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Global',
'Local'
)) {
return $true
}
if ($LanguageCode -ceq 'hr' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Manifest',
'Status',
'URL'
)) {
return $true
}
if ($LanguageCode -ceq 'es' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Error',
'Global',
'Local',
'No',
'URL'
)) {
return $true
}
if ($LanguageCode -ceq 'es-MX' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Error',
'Global',
'Local',
'No',
'URL',
'Url'
)) {
return $true
}
if ($LanguageCode -ceq 'fr' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Ascendant',
'Descendant',
'Global',
'Local',
'Machine | Global',
'Navigation',
'Portable',
'Source',
'Sources',
'Verbose',
'Version',
'installation',
'option',
'version {0}',
'{0} minutes'
)) {
return $true
}
if ($LanguageCode -ceq 'nl' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'1 week',
'Filters',
'Manifest',
'Status',
'Updates',
'URL',
'update',
'website',
'{0} status'
)) {
return $true
}
if ($LanguageCode -ceq 'sk' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Manifest',
'Text'
)) {
return $true
}
if ($LanguageCode -ceq 'de' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Global',
'Manifest',
'Name',
'Repository',
'Start',
'Status',
'Text',
'Updates',
'URL',
'Verbose',
'Version',
'Version:',
'optional',
'{package} Installation',
'{package} Update'
)) {
return $true
}
if ($LanguageCode -ceq 'sv' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Global',
'Manifest',
'Start',
'Status',
'Text',
'Version',
'Version:',
'installation',
'version {0}',
'{0} installation',
'{0} status',
'{pm} version:'
)) {
return $true
}
if ($LanguageCode -ceq 'id' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Global',
'Grid',
'Manifest',
'Status',
'URL',
'Verbose',
'{0} status'
)) {
return $true
}
if ($LanguageCode -ceq 'fil' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Android Subsystem',
'Default',
'Error',
'Global',
'Grid',
'Machine | Global',
'Manifest',
'OK',
'Ok',
'Package',
'Password',
'Portable',
'Portable mode',
'PreRelease',
'Repository',
'Source',
'Source:',
'Telemetry',
'Text',
'URL',
'UniGetUI',
'UniGetUI - {0} {1}',
'User',
'Username',
'Verbose',
'website',
'library'
)) {
return $true
}
if ($LanguageCode -ceq 'tl' -and $SourceValue -ceq $TargetValue -and $SourceValue -in @(
'Machine | Global'
)) {
return $true
}
return $false
}
@@ -0,0 +1,416 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$script:ProjectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
$script:LanguageRemap = [ordered]@{
'pt-BR' = 'pt_BR'
'pt-PT' = 'pt_PT'
'nn-NO' = 'nn'
'uk' = 'ua'
'zh-Hans' = 'zh_CN'
'zh-Hant' = 'zh_TW'
}
$script:LanguageFlagsRemap = [ordered]@{
'af' = 'za'
'ar' = 'sa'
'bs' = 'ba'
'ca' = 'ad'
'cs' = 'cz'
'da' = 'dk'
'el' = 'gr'
'en' = 'gb'
'eo' = 'https://upload.wikimedia.org/wikipedia/commons/f/f5/Flag_of_Esperanto.svg'
'es-MX' = 'mx'
'et' = 'ee'
'fa' = 'ir'
'fil' = 'ph'
'gl' = 'es'
'he' = 'il'
'hi' = 'in'
'ja' = 'jp'
'ka' = 'ge'
'ko' = 'kr'
'ku' = 'iq'
'mr' = 'in'
'nb' = 'no'
'nn' = 'no'
'pt_BR' = 'br'
'pt_PT' = 'pt'
'si' = 'lk'
'sr' = 'rs'
'sv' = 'se'
'sl' = 'si'
'ta' = 'in'
'vi' = 'vn'
'zh_CN' = 'cn'
'zh_TW' = 'tw'
'zh' = 'cn'
'bn' = 'bd'
'tg' = 'ph'
'sq' = 'al'
'kn' = 'in'
'sa' = 'in'
'gu' = 'in'
'ur' = 'pk'
'be' = 'by'
}
function Get-ProjectRoot {
return $script:ProjectRoot
}
function Get-ContributorsListPath {
return Join-Path (Get-ProjectRoot) 'src\UniGetUI.Core.Data\Assets\Data\Contributors.list'
}
function Get-TranslatorsJsonPath {
return Join-Path (Get-ProjectRoot) 'src\Languages\Data\Translators.json'
}
function Get-TranslatedPercentagesJsonPath {
return Join-Path (Get-ProjectRoot) 'src\Languages\Data\TranslatedPercentages.json'
}
function Get-LanguagesReferenceJsonPath {
return Join-Path (Get-ProjectRoot) 'src\Languages\Data\LanguagesReference.json'
}
function Get-LanguagesDirectoryPath {
return Join-Path (Get-ProjectRoot) 'src\Languages'
}
function Read-JsonDictionary {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -Path $Path -PathType Leaf)) {
return [ordered]@{}
}
$content = [System.IO.File]::ReadAllText($Path)
if ([string]::IsNullOrWhiteSpace($content)) {
return [ordered]@{}
}
$parsed = $content | ConvertFrom-Json -AsHashtable
if ($null -eq $parsed) {
return [ordered]@{}
}
if (-not ($parsed -is [System.Collections.IDictionary])) {
throw "JSON root must be an object: $Path"
}
return $parsed
}
function Get-ContributorsList {
$path = Get-ContributorsListPath
if (-not (Test-Path -Path $path -PathType Leaf)) {
return @()
}
return @(
Get-Content -Path $path -Encoding UTF8 |
ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
)
}
function Get-LanguageCredits {
return Read-JsonDictionary -Path (Get-TranslatorsJsonPath)
}
function Get-TranslatedPercentages {
return Read-JsonDictionary -Path (Get-TranslatedPercentagesJsonPath)
}
function Get-LanguageReference {
return Read-JsonDictionary -Path (Get-LanguagesReferenceJsonPath)
}
function Get-LanguageRemap {
return [ordered]@{} + $script:LanguageRemap
}
function Get-LanguageFlagsRemap {
return [ordered]@{} + $script:LanguageFlagsRemap
}
function Get-TranslatorsFromCredits {
param(
[AllowNull()]
[string]$Credits
)
if ([string]::IsNullOrWhiteSpace($Credits)) {
return @()
}
$contributors = Get-ContributorsList
$translatorLookup = @{}
foreach ($translator in ($Credits -split ',')) {
$trimmed = $translator.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) {
continue
}
$wasPrefixed = $trimmed.StartsWith('@', [System.StringComparison]::Ordinal)
if ($wasPrefixed) {
$trimmed = $trimmed.Substring(1)
}
$link = ''
if ($wasPrefixed -or ($contributors -contains $trimmed)) {
$link = "https://github.com/$trimmed"
}
$translatorLookup[$trimmed] = [pscustomobject]@{
name = $trimmed
link = $link
}
}
return @(
$translatorLookup.Keys |
Sort-Object { $_.ToLowerInvariant() } |
ForEach-Object { $translatorLookup[$_] }
)
}
function ConvertTo-TranslatorMarkdown {
param(
[AllowNull()]
[object]$Translators
)
if ($null -eq $Translators) {
return ''
}
$translatorItems = @()
if ($Translators -is [string]) {
$translatorItems = Get-TranslatorsFromCredits -Credits $Translators
}
else {
$translatorItems = @($Translators)
}
$formatted = foreach ($translator in $translatorItems) {
$name = [string]$translator.name
$link = if ($null -eq $translator.link) { '' } else { [string]$translator.link }
if ([string]::IsNullOrWhiteSpace($link)) {
$name
}
else {
"[$name]($link)"
}
}
return ($formatted -join ', ')
}
function Get-LanguageFilePathMap {
param(
[switch]$AbsolutePaths
)
$languageReference = Get-LanguageReference
$languagesDirectory = Get-LanguagesDirectoryPath
$result = [ordered]@{}
foreach ($entry in $languageReference.GetEnumerator()) {
$code = [string]$entry.Key
if ($code -eq 'default') {
continue
}
$fileName = "lang_$code.json"
$result[$code] = if ($AbsolutePaths.IsPresent) { Join-Path $languagesDirectory $fileName } else { $fileName }
}
return $result
}
function Get-MarkdownSupportLangs {
return Get-MarkdownTranslationsTable
}
function Get-MarkdownTranslationsTable {
param(
[switch]$IncludeZeroPercent,
[switch]$IncludeLanguageCode,
[switch]$IncludeFileColumn,
[bool]$IncludeTranslatedColumn = $true,
[bool]$IncludeCreditsColumn = $true,
[string]$MissingCreditsText = ''
)
$languageReference = Get-LanguageReference
$translationPercentages = Get-TranslatedPercentages
$languageCredits = Get-LanguageCredits
$flagRemap = Get-LanguageFlagsRemap
$languagesDirectory = Get-LanguagesDirectoryPath
$lines = New-Object System.Collections.Generic.List[string]
$headers = @('Language')
$alignments = @(':--')
if ($IncludeLanguageCode.IsPresent) {
$headers += 'Code'
$alignments += ':--'
}
if ($IncludeTranslatedColumn) {
$headers += 'Translated'
$alignments += ':--'
}
if ($IncludeFileColumn.IsPresent) {
$headers += 'File'
$alignments += ':--'
}
if ($IncludeCreditsColumn) {
$headers += 'Contributor(s)'
$alignments += '---'
}
$lines.Add('| ' + ($headers -join ' | ') + ' |')
$lines.Add('| ' + ($alignments -join ' | ') + ' |')
foreach ($entry in $languageReference.GetEnumerator()) {
$languageCode = [string]$entry.Key
if ($languageCode -eq 'default') {
continue
}
$languageFilePath = Join-Path $languagesDirectory ("lang_$languageCode.json")
if (-not (Test-Path -Path $languageFilePath -PathType Leaf)) {
continue
}
$percentage = if ($translationPercentages.Contains($languageCode)) { [string]$translationPercentages[$languageCode] } else { '100%' }
if ($percentage -eq '0%' -and -not $IncludeZeroPercent.IsPresent) {
continue
}
$languageName = [string]$entry.Value
$flag = if ($flagRemap.Contains($languageCode)) { [string]$flagRemap[$languageCode] } else { $languageCode }
$credits = ''
if ($IncludeCreditsColumn) {
$credits = if ($languageCredits.Contains($languageCode)) {
ConvertTo-TranslatorMarkdown -Translators $languageCredits[$languageCode]
}
else {
''
}
if ([string]::IsNullOrWhiteSpace($credits) -and -not [string]::IsNullOrWhiteSpace($MissingCreditsText)) {
$credits = $MissingCreditsText
}
}
$flagImageSource = if ([string]$flag -match '^[a-z]+://') { [string]$flag } else { "https://flagcdn.com/$flag.svg" }
$row = New-Object System.Collections.Generic.List[string]
$row.Add("<img src='$flagImageSource' width=20> &nbsp; $languageName")
if ($IncludeLanguageCode.IsPresent) {
$row.Add(('`{0}`' -f $languageCode))
}
if ($IncludeTranslatedColumn) {
$row.Add($percentage)
}
if ($IncludeFileColumn.IsPresent) {
$relativeLanguageFilePath = (Join-Path 'src/Languages' ("lang_{0}.json" -f $languageCode)) -replace '\\', '/'
$row.Add("[lang_$languageCode.json]($relativeLanguageFilePath)")
}
if ($IncludeCreditsColumn) {
$row.Add($credits)
}
$lines.Add('| ' + ($row -join ' | ') + ' |')
}
$lines.Add('')
return ($lines -join [Environment]::NewLine)
}
function Get-TranslationDocumentationMarkdown {
$coverageTable = Get-MarkdownTranslationsTable -IncludeZeroPercent -IncludeLanguageCode -IncludeFileColumn -IncludeCreditsColumn:$false
$contributorsTable = Get-MarkdownTranslationsTable -IncludeZeroPercent -IncludeLanguageCode -IncludeTranslatedColumn:$false
$sections = @(
'# Translations',
'',
'UniGetUI includes translations for the languages listed below.',
'',
'This page lists the supported languages, each locale file, current completion status, and the credited contributors for each translation.',
'',
'If you would like to help improve a translation or report an issue, please open an issue or submit a pull request.',
'',
'Translation discussion and coordination also happens in [GitHub discussion #4510](https://github.com/Devolutions/UniGetUI/discussions/4510).',
'',
'## Language Coverage',
'',
$coverageTable.TrimEnd(),
'',
'## Contributors',
'',
'We are grateful to everyone who contributes translations to UniGetUI. Contributor credits are sourced from [Translators.json](src/Languages/Data/Translators.json). If you would like to be added to or removed from the list for a particular language, please open a pull request.',
'',
$contributorsTable.TrimEnd(),
'',
'## Maintaining Translation Data',
'',
'The tables in this document are generated from the checked-in translation metadata. Completion is computed against the active English keys in `lang_en.json`; keys below the legacy boundary marker are excluded from the percentage.',
'',
'To refresh translated percentages, contributor metadata, and this document after locale changes, run:',
'',
'```powershell',
'pwsh ./scripts/translation/Sync-TranslationMetadata.ps1 -AllLanguages -UpdateTranslationDoc',
'```',
'',
'To inspect the current status without modifying files, run:',
'',
'```powershell',
'pwsh ./scripts/translation/Get-TranslationStatus.ps1 -OutputFormat Markdown -OnlyIncomplete',
'```',
''
)
return (($sections -join [Environment]::NewLine) + [Environment]::NewLine)
}
Export-ModuleMember -Function @(
'Get-ProjectRoot',
'Get-ContributorsListPath',
'Get-TranslatorsJsonPath',
'Get-TranslatedPercentagesJsonPath',
'Get-LanguagesReferenceJsonPath',
'Get-LanguagesDirectoryPath',
'Get-ContributorsList',
'Get-LanguageCredits',
'Get-TranslatedPercentages',
'Get-LanguageReference',
'Get-LanguageRemap',
'Get-LanguageFlagsRemap',
'Get-TranslatorsFromCredits',
'ConvertTo-TranslatorMarkdown',
'Get-MarkdownSupportLangs',
'Get-MarkdownTranslationsTable',
'Get-TranslationDocumentationMarkdown',
'Get-LanguageFilePathMap'
)
@@ -0,0 +1,19 @@
[CmdletBinding()]
param(
[switch]$AsJson,
[switch]$AbsolutePaths
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'LanguageData.psm1') -Force
$languageFileMap = Get-LanguageFilePathMap -AbsolutePaths:$AbsolutePaths.IsPresent
if ($AsJson.IsPresent) {
$languageFileMap | ConvertTo-Json -Depth 5
return
}
$languageFileMap
@@ -0,0 +1,497 @@
Set-StrictMode -Version Latest
$script:TranslationLegacyBoundaryKey = '__LEGACY_TRANSLATION_KEYS_BELOW__'
$script:TranslationLegacyBoundaryValue = 'Legacy translation keys below are kept for backward compatibility with older UniGetUI builds. Do not translate or remove yet.'
function Assert-Command {
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
if (-not (Get-Command -Name $Name -ErrorAction SilentlyContinue)) {
throw "Required command not found on PATH: $Name"
}
}
function Get-FullPath {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
return [System.IO.Path]::GetFullPath($Path)
}
function New-Utf8File {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Content
)
$directory = Split-Path -Path $Path -Parent
if (-not [string]::IsNullOrWhiteSpace($directory)) {
New-Item -Path $directory -ItemType Directory -Force | Out-Null
}
$encoding = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
}
function New-OrderedStringMap {
return [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal)
}
function Assert-NoDuplicateJsonKeys {
param(
[Parameter(Mandatory = $true)]
[string]$Content,
[Parameter(Mandatory = $true)]
[string]$Path
)
$pattern = [regex]'(?m)^\s*"((?:\\.|[^"])*)"\s*:'
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
$duplicates = New-Object System.Collections.Generic.List[string]
foreach ($match in $pattern.Matches($Content)) {
$key = [regex]::Unescape($match.Groups[1].Value)
if (-not $seen.Add($key) -and -not $duplicates.Contains($key)) {
$duplicates.Add($key)
}
}
if ($duplicates.Count -gt 0) {
throw "JSON file contains duplicate key(s): $($duplicates -join ', '). Path: $Path"
}
}
function Read-OrderedJsonMap {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -Path $Path -PathType Leaf)) {
return (New-OrderedStringMap)
}
$content = [System.IO.File]::ReadAllText((Get-FullPath -Path $Path))
return Convert-JsonContentToOrderedMap -Content $content -Path $Path -DetectDuplicates
}
function Read-OrderedJsonMapPermissive {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -Path $Path -PathType Leaf)) {
return (New-OrderedStringMap)
}
$content = [System.IO.File]::ReadAllText((Get-FullPath -Path $Path))
return Convert-JsonContentToOrderedMap -Content $content -Path $Path
}
function Convert-JsonContentToOrderedMap {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Content,
[Parameter(Mandatory = $true)]
[string]$Path,
[switch]$DetectDuplicates
)
$result = New-OrderedStringMap
if ([string]::IsNullOrWhiteSpace($Content)) {
return $result
}
if ($DetectDuplicates) {
Assert-NoDuplicateJsonKeys -Content $Content -Path $Path
}
$document = [System.Text.Json.JsonDocument]::Parse($Content)
try {
if ($document.RootElement.ValueKind -eq [System.Text.Json.JsonValueKind]::Null) {
return $result
}
if ($document.RootElement.ValueKind -ne [System.Text.Json.JsonValueKind]::Object) {
throw "JSON file must contain a flat object: $Path"
}
foreach ($property in $document.RootElement.EnumerateObject()) {
if ($property.Value.ValueKind -eq [System.Text.Json.JsonValueKind]::Object -or $property.Value.ValueKind -eq [System.Text.Json.JsonValueKind]::Array) {
throw "JSON file must contain a flat object with string values only: $Path"
}
if ($property.Value.ValueKind -eq [System.Text.Json.JsonValueKind]::String) {
$result[$property.Name] = $property.Value.GetString()
continue
}
if ($property.Value.ValueKind -eq [System.Text.Json.JsonValueKind]::Null) {
$result[$property.Name] = ''
continue
}
$result[$property.Name] = $property.Value.ToString()
}
}
finally {
$document.Dispose()
}
return $result
}
function ConvertTo-JsonStringLiteral {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Value
)
return ($Value | ConvertTo-Json -Compress)
}
function Write-OrderedJsonMap {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$Map
)
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('{')
$index = 0
$count = $Map.Count
foreach ($entry in $Map.GetEnumerator()) {
$index += 1
$line = ' {0}: {1}' -f (ConvertTo-JsonStringLiteral -Value ([string]$entry.Key)), (ConvertTo-JsonStringLiteral -Value ([string]$entry.Value))
if ($index -lt $count) {
$line += ','
}
$lines.Add($line)
}
$lines.Add('}')
New-Utf8File -Path $Path -Content (($lines -join "`r`n") + "`r`n")
}
function Get-TranslationLegacyBoundaryKey {
return $script:TranslationLegacyBoundaryKey
}
function Get-TranslationLegacyBoundaryValue {
return $script:TranslationLegacyBoundaryValue
}
function Split-TranslationMapAtBoundary {
param(
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$Map
)
$boundaryKey = Get-TranslationLegacyBoundaryKey
$activeMap = New-OrderedStringMap
$legacyMap = New-OrderedStringMap
$hasBoundary = $false
$boundaryReached = $false
foreach ($entry in $Map.GetEnumerator()) {
$key = [string]$entry.Key
$value = [string]$entry.Value
if ($key -ceq $boundaryKey) {
$hasBoundary = $true
$boundaryReached = $true
continue
}
if ($boundaryReached) {
$legacyMap[$key] = $value
}
else {
$activeMap[$key] = $value
}
}
return [pscustomobject]@{
HasBoundary = $hasBoundary
ActiveMap = $activeMap
LegacyMap = $legacyMap
}
}
function Join-TranslationMapWithBoundary {
param(
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$ActiveMap,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$LegacyMap,
[switch]$IncludeBoundary
)
$result = New-OrderedStringMap
foreach ($entry in $ActiveMap.GetEnumerator()) {
$result[[string]$entry.Key] = [string]$entry.Value
}
if ($IncludeBoundary) {
$result[(Get-TranslationLegacyBoundaryKey)] = Get-TranslationLegacyBoundaryValue
}
foreach ($entry in $LegacyMap.GetEnumerator()) {
$result[[string]$entry.Key] = [string]$entry.Value
}
return $result
}
function Test-OrderedStringMapsEqual {
param(
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$Left,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$Right
)
if ($Left.Count -ne $Right.Count) {
return $false
}
$leftEntries = @($Left.GetEnumerator())
$rightEntries = @($Right.GetEnumerator())
for ($index = 0; $index -lt $leftEntries.Count; $index += 1) {
if ([string]$leftEntries[$index].Key -cne [string]$rightEntries[$index].Key) {
return $false
}
if ([string]$leftEntries[$index].Value -cne [string]$rightEntries[$index].Value) {
return $false
}
}
return $true
}
function Invoke-CirupJson {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments
)
Assert-Command -Name 'cirup'
$output = & cirup @Arguments --output-format json
if ($LASTEXITCODE -ne 0) {
throw "cirup command failed: cirup $($Arguments -join ' ')"
}
$jsonText = [string]::Join([Environment]::NewLine, @($output)).Trim()
if ([string]::IsNullOrWhiteSpace($jsonText)) {
return @()
}
$parsed = $jsonText | ConvertFrom-Json -AsHashtable
return @($parsed)
}
function Get-RepositoryRoot {
param(
[Parameter(Mandatory = $true)]
[string]$WorkingDirectory
)
$repoRoot = & git -C $WorkingDirectory rev-parse --show-toplevel
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) {
throw "Unable to resolve git repository root from '$WorkingDirectory'."
}
return $repoRoot.Trim()
}
function Get-RepoRelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$RepositoryRoot,
[Parameter(Mandatory = $true)]
[string]$FilePath
)
$repoRootFull = Get-FullPath -Path $RepositoryRoot
$filePathFull = Get-FullPath -Path $FilePath
$repoRootWithSeparator = $repoRootFull.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
if (-not $filePathFull.StartsWith($repoRootWithSeparator, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "File '$filePathFull' is not located under repository root '$repoRootFull'."
}
return [System.IO.Path]::GetRelativePath($repoRootFull, $filePathFull).Replace('\', '/')
}
function Get-SafeLabel {
param(
[Parameter(Mandatory = $true)]
[string]$Value
)
$safe = $Value -replace '[^A-Za-z0-9._-]+', '-'
$safe = $safe.Trim('-')
if ([string]::IsNullOrWhiteSpace($safe)) {
return 'base'
}
return $safe
}
function Get-PatchBaseName {
param(
[Parameter(Mandatory = $true)]
[string]$NeutralJsonPath
)
$stem = [System.IO.Path]::GetFileNameWithoutExtension($NeutralJsonPath)
if ($stem -match '^(.*)_en$' -and -not [string]::IsNullOrWhiteSpace($matches[1])) {
return $matches[1]
}
return $stem
}
function Get-LanguagesReferencePath {
param(
[Parameter(Mandatory = $true)]
[string]$NeutralJsonPath
)
$languagesDirectory = Split-Path -Path (Get-FullPath -Path $NeutralJsonPath) -Parent
return Join-Path $languagesDirectory 'Data\LanguagesReference.json'
}
function Assert-LanguageCodeKnown {
param(
[Parameter(Mandatory = $true)]
[string]$LanguagesReferencePath,
[Parameter(Mandatory = $true)]
[string]$LanguageCode
)
$referenceMap = Read-OrderedJsonMap -Path $LanguagesReferencePath
if (-not $referenceMap.Contains($LanguageCode)) {
throw "Unknown language code '$LanguageCode'. Expected one of the language codes from $LanguagesReferencePath"
}
}
function Test-NeedsTranslation {
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$SourceMap,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$TargetMap
)
if (-not $TargetMap.Contains($Key)) {
return $true
}
$targetValue = [string]$TargetMap[$Key]
if ([string]::IsNullOrWhiteSpace($targetValue)) {
return $true
}
return $targetValue -ceq [string]$SourceMap[$Key]
}
function Get-PlaceholderTokens {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Value
)
$tokens = foreach ($match in [regex]::Matches($Value, '\{[A-Za-z0-9_]+(?:,[^}:]+)?(?::[^}]+)?\}')) {
$match.Value
}
return @($tokens | Sort-Object)
}
function Get-HtmlTokens {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Value
)
$tokens = foreach ($match in [regex]::Matches($Value, '</?[^>]+?>')) {
$match.Value
}
return @($tokens | Sort-Object)
}
function Get-NewlineCount {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Value
)
return ([regex]::Matches($Value, "`r`n|`n")).Count
}
function Assert-TranslationStructure {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$SourceValue,
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$TranslatedValue,
[Parameter(Mandatory = $true)]
[string]$Key
)
$sourcePlaceholderSignature = (Get-PlaceholderTokens -Value $SourceValue) -join "`n"
$translatedPlaceholderSignature = (Get-PlaceholderTokens -Value $TranslatedValue) -join "`n"
if ($sourcePlaceholderSignature -ne $translatedPlaceholderSignature) {
throw "Placeholder mismatch for key '$Key'."
}
$sourceHtmlSignature = (Get-HtmlTokens -Value $SourceValue) -join "`n"
$translatedHtmlSignature = (Get-HtmlTokens -Value $TranslatedValue) -join "`n"
if ($sourceHtmlSignature -ne $translatedHtmlSignature) {
throw "HTML fragment mismatch for key '$Key'."
}
if ((Get-NewlineCount -Value $SourceValue) -ne (Get-NewlineCount -Value $TranslatedValue)) {
throw "Line-break mismatch for key '$Key'."
}
}
@@ -0,0 +1,323 @@
Set-StrictMode -Version Latest
. (Join-Path $PSScriptRoot 'TranslationJsonTools.ps1')
$script:SupportedSourceExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
[void]$script:SupportedSourceExtensions.Add('.cs')
[void]$script:SupportedSourceExtensions.Add('.xaml')
[void]$script:SupportedSourceExtensions.Add('.axaml')
$script:ExcludedDirectoryNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($name in @('.git', '.vs', 'bin', 'obj', 'generated', 'node_modules', 'packages')) {
[void]$script:ExcludedDirectoryNames.Add($name)
}
function Resolve-TranslationSyncRepositoryRoot {
param(
[string]$RepositoryRoot
)
if (-not [string]::IsNullOrWhiteSpace($RepositoryRoot)) {
return Get-FullPath -Path $RepositoryRoot
}
return [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
}
function Resolve-EnglishLanguageFilePath {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedRepositoryRoot,
[string]$EnglishFilePath
)
if (-not [string]::IsNullOrWhiteSpace($EnglishFilePath)) {
return Get-FullPath -Path $EnglishFilePath
}
return Join-Path $ResolvedRepositoryRoot 'src\Languages\lang_en.json'
}
function Test-TranslationSourceFileIncluded {
param(
[Parameter(Mandatory = $true)]
[System.IO.FileInfo]$File,
[Parameter(Mandatory = $true)]
[string]$ResolvedRepositoryRoot
)
if (-not $script:SupportedSourceExtensions.Contains($File.Extension)) {
return $false
}
$relativePath = [System.IO.Path]::GetRelativePath($ResolvedRepositoryRoot, $File.FullName).Replace('\', '/')
foreach ($segment in $relativePath.Split('/')) {
if ($script:ExcludedDirectoryNames.Contains($segment)) {
return $false
}
}
if ($relativePath -like 'src/Languages/lang_*.json') {
return $false
}
return $relativePath.StartsWith('src/', [System.StringComparison]::OrdinalIgnoreCase)
}
function Get-TranslationSourceFiles {
param(
[Parameter(Mandatory = $true)]
[string]$ResolvedRepositoryRoot
)
return @(
Get-ChildItem -Path $ResolvedRepositoryRoot -File -Recurse -ErrorAction SilentlyContinue |
Where-Object { Test-TranslationSourceFileIncluded -File $_ -ResolvedRepositoryRoot $ResolvedRepositoryRoot } |
Sort-Object FullName
)
}
function Get-LineNumberFromIndex {
param(
[Parameter(Mandatory = $true)]
[string]$Text,
[Parameter(Mandatory = $true)]
[int]$Index
)
if ($Index -le 0) {
return 1
}
return ([regex]::Matches($Text.Substring(0, $Index), "`r`n|`n")).Count + 1
}
function Convert-CSharpStringLiteralValue {
param(
[Parameter(Mandatory = $true)]
[string]$Literal
)
if ($Literal.StartsWith('@"', [System.StringComparison]::Ordinal)) {
return $Literal.Substring(2, $Literal.Length - 3).Replace('""', '"')
}
return [regex]::Unescape($Literal.Substring(1, $Literal.Length - 2))
}
function Add-TranslationSourceKey {
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[Parameter(Mandatory = $true)]
[string]$SourcePath,
[Parameter(Mandatory = $true)]
[int]$LineNumber,
[Parameter(Mandatory = $true)]
[System.Collections.IList]$KeyOrder,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$SourcesByKey
)
if ([string]::IsNullOrWhiteSpace($Key)) {
return
}
if (-not $SourcesByKey.Contains($Key)) {
$SourcesByKey[$Key] = New-Object System.Collections.Generic.List[string]
$KeyOrder.Add($Key)
}
$location = '{0}:{1}' -f $SourcePath.Replace('\', '/'), $LineNumber
$locations = [System.Collections.Generic.List[string]]$SourcesByKey[$Key]
if (-not $locations.Contains($location)) {
$locations.Add($location)
}
}
function Add-TranslationSourceWarning {
param(
[Parameter(Mandatory = $true)]
[string]$Type,
[Parameter(Mandatory = $true)]
[string]$SourcePath,
[Parameter(Mandatory = $true)]
[int]$LineNumber,
[Parameter(Mandatory = $true)]
[string]$Message,
[Parameter(Mandatory = $true)]
[System.Collections.IList]$Warnings
)
$Warnings.Add([pscustomobject]@{
Type = $Type
Path = $SourcePath.Replace('\', '/')
Line = $LineNumber
Message = $Message
})
}
function Get-CSharpTranslationMatches {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string]$Content,
[Parameter(Mandatory = $true)]
[System.Collections.IList]$KeyOrder,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$SourcesByKey,
[Parameter(Mandatory = $true)]
[System.Collections.IList]$Warnings
)
$literalPattern = [regex]'CoreTools\s*\.\s*(?:Translate|AutoTranslated)\(\s*(?<literal>@"(?:[^"]|"")*"|"(?:\\.|[^"\\])*")'
foreach ($match in $literalPattern.Matches($Content)) {
$literal = [string]$match.Groups['literal'].Value
$lineNumber = Get-LineNumberFromIndex -Text $Content -Index $match.Index
Add-TranslationSourceKey -Key (Convert-CSharpStringLiteralValue -Literal $literal) -SourcePath $FilePath -LineNumber $lineNumber -KeyOrder $KeyOrder -SourcesByKey $SourcesByKey
}
$interpolatedPattern = [regex]'CoreTools\s*\.\s*Translate\(\s*\$@?"'
foreach ($match in $interpolatedPattern.Matches($Content)) {
$lineNumber = Get-LineNumberFromIndex -Text $Content -Index $match.Index
Add-TranslationSourceWarning -Type 'InterpolatedTranslateCall' -SourcePath $FilePath -LineNumber $lineNumber -Message 'Interpolated CoreTools.Translate call is not synchronized automatically.' -Warnings $Warnings
}
}
function Get-TranslatedTextBlockMatches {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string]$Content,
[Parameter(Mandatory = $true)]
[System.Collections.IList]$KeyOrder,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$SourcesByKey
)
$translatedTextBlockPattern = [regex]'<(?<tag>[A-Za-z0-9_:.\-]+TranslatedTextBlock)\b[^>]*\bText="(?<text>[^"]*)"'
foreach ($match in $translatedTextBlockPattern.Matches($Content)) {
$lineNumber = Get-LineNumberFromIndex -Text $Content -Index $match.Index
$text = [System.Net.WebUtility]::HtmlDecode([string]$match.Groups['text'].Value)
Add-TranslationSourceKey -Key $text -SourcePath $FilePath -LineNumber $lineNumber -KeyOrder $KeyOrder -SourcesByKey $SourcesByKey
}
}
function Get-AvaloniaTranslateMarkupMatches {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string]$Content,
[Parameter(Mandatory = $true)]
[System.Collections.IList]$KeyOrder,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$SourcesByKey,
[Parameter(Mandatory = $true)]
[System.Collections.IList]$Warnings
)
$markupPattern = [regex]'\{t:Translate(?<body>[^}]*)\}'
foreach ($match in $markupPattern.Matches($Content)) {
$body = [string]$match.Groups['body'].Value
$trimmedBody = $body.Trim()
$lineNumber = Get-LineNumberFromIndex -Text $Content -Index $match.Index
if ([string]::IsNullOrWhiteSpace($trimmedBody)) {
Add-TranslationSourceWarning -Type 'EmptyMarkupTranslate' -SourcePath $FilePath -LineNumber $lineNumber -Message 'Empty {t:Translate} markup extension was ignored.' -Warnings $Warnings
continue
}
$namedMatch = [regex]::Match($trimmedBody, '^Text\s*=\s*(?:''(?<single>[^'']*)''|"(?<double>[^"]*)")$')
if ($namedMatch.Success) {
$value = if ($namedMatch.Groups['single'].Success) { $namedMatch.Groups['single'].Value } else { $namedMatch.Groups['double'].Value }
Add-TranslationSourceKey -Key ([System.Net.WebUtility]::HtmlDecode($value)) -SourcePath $FilePath -LineNumber $lineNumber -KeyOrder $KeyOrder -SourcesByKey $SourcesByKey
continue
}
Add-TranslationSourceKey -Key ([System.Net.WebUtility]::HtmlDecode($trimmedBody)) -SourcePath $FilePath -LineNumber $lineNumber -KeyOrder $KeyOrder -SourcesByKey $SourcesByKey
}
}
function Get-TranslationSourceSnapshot {
param(
[string]$RepositoryRoot
)
$resolvedRepositoryRoot = Resolve-TranslationSyncRepositoryRoot -RepositoryRoot $RepositoryRoot
$sourceFiles = Get-TranslationSourceFiles -ResolvedRepositoryRoot $resolvedRepositoryRoot
$keyOrder = New-Object System.Collections.Generic.List[string]
$sourcesByKey = [ordered]@{}
$warnings = New-Object System.Collections.Generic.List[object]
foreach ($file in $sourceFiles) {
$relativePath = [System.IO.Path]::GetRelativePath($resolvedRepositoryRoot, $file.FullName).Replace('\', '/')
$content = [System.IO.File]::ReadAllText($file.FullName)
switch ($file.Extension.ToLowerInvariant()) {
'.cs' {
Get-CSharpTranslationMatches -FilePath $relativePath -Content $content -KeyOrder $keyOrder -SourcesByKey $sourcesByKey -Warnings $warnings
}
'.xaml' {
Get-TranslatedTextBlockMatches -FilePath $relativePath -Content $content -KeyOrder $keyOrder -SourcesByKey $sourcesByKey
}
'.axaml' {
Get-TranslatedTextBlockMatches -FilePath $relativePath -Content $content -KeyOrder $keyOrder -SourcesByKey $sourcesByKey
Get-AvaloniaTranslateMarkupMatches -FilePath $relativePath -Content $content -KeyOrder $keyOrder -SourcesByKey $sourcesByKey -Warnings $warnings
}
}
}
$orderedKeyMap = New-OrderedStringMap
foreach ($key in $keyOrder) {
$orderedKeyMap[$key] = $key
}
$sourceFilePaths = New-Object System.Collections.Generic.List[string]
foreach ($sourceFile in $sourceFiles) {
$sourceFilePaths.Add($sourceFile.FullName)
}
$warningItems = New-Object System.Collections.Generic.List[object]
foreach ($warning in $warnings) {
$warningItems.Add($warning)
}
$sourceFileArray = $sourceFilePaths.ToArray()
$keyOrderArray = $keyOrder.ToArray()
$warningArray = $warningItems.ToArray()
$snapshot = New-Object -TypeName psobject
$snapshot | Add-Member -NotePropertyName RepositoryRoot -NotePropertyValue $resolvedRepositoryRoot
$snapshot | Add-Member -NotePropertyName SourceFiles -NotePropertyValue $sourceFileArray
$snapshot | Add-Member -NotePropertyName KeyOrder -NotePropertyValue $keyOrderArray
$snapshot | Add-Member -NotePropertyName Keys -NotePropertyValue $orderedKeyMap
$snapshot | Add-Member -NotePropertyName SourcesByKey -NotePropertyValue $sourcesByKey
$snapshot | Add-Member -NotePropertyName Warnings -NotePropertyValue $warningArray
return $snapshot
}