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,146 @@
[CmdletBinding()]
param(
[string]$RepositoryRoot,
[string]$EnglishFilePath,
[string]$LanguagesDirectory,
[string]$OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
. (Join-Path $PSScriptRoot 'Languages\TranslationSourceTools.ps1')
$resolvedRepositoryRoot = Resolve-TranslationSyncRepositoryRoot -RepositoryRoot $RepositoryRoot
$resolvedEnglishFilePath = Resolve-EnglishLanguageFilePath -ResolvedRepositoryRoot $resolvedRepositoryRoot -EnglishFilePath $EnglishFilePath
$resolvedLanguagesDirectory = if ([string]::IsNullOrWhiteSpace($LanguagesDirectory)) {
Split-Path -Path $resolvedEnglishFilePath -Parent
}
else {
Get-FullPath -Path $LanguagesDirectory
}
if (-not (Test-Path -Path $resolvedLanguagesDirectory -PathType Container)) {
throw "Languages directory not found: $resolvedLanguagesDirectory"
}
$englishMap = Read-OrderedJsonMap -Path $resolvedEnglishFilePath
$englishSections = Split-TranslationMapAtBoundary -Map $englishMap
if (-not $englishSections.HasBoundary) {
Write-Output 'The English translation file does not contain a legacy boundary marker. No alignment to export.'
return
}
$englishActiveOrder = @($englishSections.ActiveMap.Keys)
$englishLegacyOrder = @($englishSections.LegacyMap.Keys)
$englishActiveSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($key in $englishActiveOrder) {
[void]$englishActiveSet.Add([string]$key)
}
$englishLegacySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($key in $englishLegacyOrder) {
[void]$englishLegacySet.Add([string]$key)
}
$reports = New-Object System.Collections.Generic.List[object]
$languageFiles = Get-ChildItem -Path $resolvedLanguagesDirectory -Filter 'lang_*.json' | Sort-Object Name
foreach ($languageFile in $languageFiles) {
if ($languageFile.FullName -ieq $resolvedEnglishFilePath) {
continue
}
$languageMap = Read-OrderedJsonMapPermissive -Path $languageFile.FullName
$languageSections = Split-TranslationMapAtBoundary -Map $languageMap
$missingActiveKeys = New-Object System.Collections.Generic.List[string]
foreach ($key in $englishActiveOrder) {
if (-not $languageMap.Contains($key)) {
$missingActiveKeys.Add([string]$key)
}
}
$presentActiveInExpectedOrder = New-Object System.Collections.Generic.List[string]
foreach ($key in $englishActiveOrder) {
if ($languageMap.Contains($key)) {
$presentActiveInExpectedOrder.Add([string]$key)
}
}
$currentActiveInEnglishSet = New-Object System.Collections.Generic.List[string]
$extraActiveKeys = New-Object System.Collections.Generic.List[string]
foreach ($entry in $languageSections.ActiveMap.GetEnumerator()) {
$key = [string]$entry.Key
if ($englishActiveSet.Contains($key)) {
$currentActiveInEnglishSet.Add($key)
}
elseif ($key -cne (Get-TranslationLegacyBoundaryKey)) {
$extraActiveKeys.Add($key)
}
}
$presentLegacyInExpectedOrder = New-Object System.Collections.Generic.List[string]
foreach ($key in $englishLegacyOrder) {
if ($languageMap.Contains($key)) {
$presentLegacyInExpectedOrder.Add([string]$key)
}
}
$currentLegacyInEnglishSet = New-Object System.Collections.Generic.List[string]
$extraLegacyKeys = New-Object System.Collections.Generic.List[string]
foreach ($entry in $languageSections.LegacyMap.GetEnumerator()) {
$key = [string]$entry.Key
if ($englishLegacySet.Contains($key)) {
$currentLegacyInEnglishSet.Add($key)
}
else {
$extraLegacyKeys.Add($key)
}
}
$reportEntry = [pscustomobject]@{
languageFile = (Get-RepoRelativePath -RepositoryRoot $resolvedRepositoryRoot -FilePath $languageFile.FullName)
hasBoundary = $languageSections.HasBoundary
activeOrderAligned = [System.Linq.Enumerable]::SequenceEqual($currentActiveInEnglishSet.ToArray(), $presentActiveInExpectedOrder.ToArray())
legacyOrderAligned = [System.Linq.Enumerable]::SequenceEqual($currentLegacyInEnglishSet.ToArray(), $presentLegacyInExpectedOrder.ToArray())
activeKeyCount = $currentActiveInEnglishSet.Count
legacyKeyCount = $currentLegacyInEnglishSet.Count
missingActiveKeyCount = $missingActiveKeys.Count
extraActiveKeyCount = $extraActiveKeys.Count
extraLegacyKeyCount = $extraLegacyKeys.Count
missingActiveKeys = $missingActiveKeys.ToArray()
extraActiveKeys = $extraActiveKeys.ToArray()
extraLegacyKeys = $extraLegacyKeys.ToArray()
}
$reports.Add($reportEntry)
}
$report = [pscustomobject]@{
generatedAt = (Get-Date).ToString('o')
repositoryRoot = $resolvedRepositoryRoot
englishFile = (Get-RepoRelativePath -RepositoryRoot $resolvedRepositoryRoot -FilePath $resolvedEnglishFilePath)
boundaryKey = Get-TranslationLegacyBoundaryKey
englishActiveKeyCount = $englishActiveOrder.Count
englishLegacyKeyCount = $englishLegacyOrder.Count
languageCount = $reports.Count
languages = $reports.ToArray()
}
$resolvedOutputPath = if ([string]::IsNullOrWhiteSpace($OutputPath)) {
Join-Path $resolvedRepositoryRoot 'artifacts\translation\translation-boundary-alignment.json'
}
else {
Get-FullPath -Path $OutputPath
}
New-Utf8File -Path $resolvedOutputPath -Content ($report | ConvertTo-Json -Depth 6)
Write-Output 'Translation boundary alignment summary'
Write-Output "Repository root: $resolvedRepositoryRoot"
Write-Output "English file: $resolvedEnglishFilePath"
Write-Output "Language files analyzed: $($reports.Count)"
Write-Output "English active keys: $($englishActiveOrder.Count)"
Write-Output "English legacy keys: $($englishLegacyOrder.Count)"
Write-Output "Report: $resolvedOutputPath"
@@ -0,0 +1,128 @@
[CmdletBinding()]
param(
[string]$RepositoryRoot,
[string]$TranslationFilePath,
[string]$BeforeRef = 'HEAD~1',
[string]$AfterRef = 'HEAD',
[string]$OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
. (Join-Path $PSScriptRoot 'Languages\TranslationSourceTools.ps1')
$resolvedRepositoryRoot = Resolve-TranslationSyncRepositoryRoot -RepositoryRoot $RepositoryRoot
$resolvedTranslationFilePath = if ([string]::IsNullOrWhiteSpace($TranslationFilePath)) {
Join-Path $resolvedRepositoryRoot 'src\Languages\lang_en.json'
}
else {
Get-FullPath -Path $TranslationFilePath
}
if (-not (Test-Path -Path $resolvedTranslationFilePath -PathType Leaf)) {
throw "Translation file not found: $resolvedTranslationFilePath"
}
$relativePath = Get-RepoRelativePath -RepositoryRoot $resolvedRepositoryRoot -FilePath $resolvedTranslationFilePath
function Get-TranslationMap {
param(
[Parameter(Mandatory = $true)]
[string]$RepoRoot,
[Parameter(Mandatory = $true)]
[string]$Ref,
[Parameter(Mandatory = $true)]
[string]$RepoRelativePath,
[Parameter(Mandatory = $true)]
[string]$ResolvedFilePath
)
if ($Ref -in @('WORKTREE', 'CURRENT', 'FILE')) {
return Read-OrderedJsonMap -Path $ResolvedFilePath
}
$gitObject = '{0}:{1}' -f $Ref, $RepoRelativePath
$content = & git -C $RepoRoot show $gitObject 2>$null
if ($LASTEXITCODE -ne 0) {
throw "Unable to read '$RepoRelativePath' from git ref '$Ref'."
}
return Convert-JsonContentToOrderedMap -Content ([string]::Join([Environment]::NewLine, @($content))) -Path $gitObject -DetectDuplicates
}
$beforeMap = Get-TranslationMap -RepoRoot $resolvedRepositoryRoot -Ref $BeforeRef -RepoRelativePath $relativePath -ResolvedFilePath $resolvedTranslationFilePath
$afterMap = Get-TranslationMap -RepoRoot $resolvedRepositoryRoot -Ref $AfterRef -RepoRelativePath $relativePath -ResolvedFilePath $resolvedTranslationFilePath
$removedKeys = New-Object System.Collections.Generic.List[string]
foreach ($entry in $beforeMap.GetEnumerator()) {
$key = [string]$entry.Key
if (-not $afterMap.Contains($key)) {
$removedKeys.Add($key)
}
}
$addedKeys = New-Object System.Collections.Generic.List[string]
foreach ($entry in $afterMap.GetEnumerator()) {
$key = [string]$entry.Key
if (-not $beforeMap.Contains($key)) {
$addedKeys.Add($key)
}
}
$changedValues = New-Object System.Collections.Generic.List[object]
foreach ($entry in $beforeMap.GetEnumerator()) {
$key = [string]$entry.Key
if ($afterMap.Contains($key)) {
$beforeValue = [string]$entry.Value
$afterValue = [string]$afterMap[$key]
if ($beforeValue -cne $afterValue) {
$changedValues.Add([pscustomobject]@{
Key = $key
Before = $beforeValue
After = $afterValue
})
}
}
}
$removedKeysArray = $removedKeys.ToArray()
$addedKeysArray = $addedKeys.ToArray()
$changedValuesArray = $changedValues.ToArray()
$report = [pscustomobject]@{
generatedAt = (Get-Date).ToString('o')
repositoryRoot = $resolvedRepositoryRoot
translationFile = $relativePath
beforeRef = $BeforeRef
afterRef = $AfterRef
removedKeyCount = $removedKeys.Count
addedKeyCount = $addedKeys.Count
changedValueCount = $changedValues.Count
removedKeys = $removedKeysArray
addedKeys = $addedKeysArray
changedValues = $changedValuesArray
}
$resolvedOutputPath = if ([string]::IsNullOrWhiteSpace($OutputPath)) {
Join-Path $resolvedRepositoryRoot ('artifacts\translation\{0}-{1}-to-{2}.json' -f ([IO.Path]::GetFileNameWithoutExtension($relativePath)), (Get-SafeLabel -Value $BeforeRef), (Get-SafeLabel -Value $AfterRef))
}
else {
Get-FullPath -Path $OutputPath
}
New-Utf8File -Path $resolvedOutputPath -Content ($report | ConvertTo-Json -Depth 6)
Write-Output "Translation key diff summary"
Write-Output "Repository root: $resolvedRepositoryRoot"
Write-Output "Translation file: $relativePath"
Write-Output "Before ref: $BeforeRef"
Write-Output "After ref: $AfterRef"
Write-Output "Removed keys: $($removedKeys.Count)"
Write-Output "Added keys: $($addedKeys.Count)"
Write-Output "Changed values: $($changedValues.Count)"
Write-Output "Report: $resolvedOutputPath"
@@ -0,0 +1,587 @@
[CmdletBinding()]
param(
[ValidateSet('Table', 'Json', 'Markdown')]
[string]$OutputFormat = 'Table',
[switch]$IncludeEnglish,
[switch]$OnlyIncomplete,
[string]$OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'Languages\LanguageData.psm1') -Force
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
function Read-LanguageMap {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -Path $Path -PathType Leaf)) {
return (New-OrderedStringMap)
}
return (Read-OrderedJsonMap -Path $Path)
}
function Get-LanguageMapSections {
param(
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$Map
)
$sections = Split-TranslationMapAtBoundary -Map $Map
$fullMap = Join-TranslationMapWithBoundary -ActiveMap $sections.ActiveMap -LegacyMap $sections.LegacyMap
return [pscustomobject]@{
HasBoundary = $sections.HasBoundary
FullMap = $fullMap
ActiveMap = $sections.ActiveMap
LegacyMap = $sections.LegacyMap
}
}
function Get-PercentageNumber {
param(
[AllowNull()]
[object]$Value
)
if ($null -eq $Value) {
return $null
}
$text = [string]$Value
if ([string]::IsNullOrWhiteSpace($text)) {
return $null
}
$trimmed = $text.Trim()
if ($trimmed.EndsWith('%', [System.StringComparison]::Ordinal)) {
$trimmed = $trimmed.Substring(0, $trimmed.Length - 1)
}
$result = 0
if ([int]::TryParse($trimmed, [ref]$result)) {
return $result
}
return $null
}
function Get-CompletionPercentage {
param(
[int]$Completed,
[int]$Total
)
if ($Total -le 0) {
return 0
}
if ($Completed -ge $Total) {
return 100
}
$rounded = [int][Math]::Round(($Completed / $Total) * 100, 0, [MidpointRounding]::AwayFromZero)
return [Math]::Min($rounded, 99)
}
function Test-KnownPlaceholderLocale {
param(
[Parameter(Mandatory = $true)]
[string]$LanguageCode
)
return $false
}
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}',
'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 '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',
'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
}
return $false
}
. (Join-Path $PSScriptRoot 'Languages\IntentionalSourceEqualValues.ps1')
function Get-TranslationStatusRow {
param(
[Parameter(Mandatory = $true)]
[string]$LanguageCode,
[Parameter(Mandatory = $true)]
[string]$LanguageName,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$NeutralMap,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$NeutralLegacyMap,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$TargetMap,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$TargetLegacyMap,
[AllowNull()]
[Nullable[int]]$StoredPercentage,
[Parameter(Mandatory = $true)]
[bool]$HasFile,
[Parameter(Mandatory = $true)]
[bool]$HasBoundary
)
$totalKeys = $NeutralMap.Count
$missingKeys = 0
$emptyKeys = 0
$sourceEqualKeys = 0
$translatedKeys = 0
$extraKeys = 0
foreach ($entry in $NeutralMap.GetEnumerator()) {
$key = [string]$entry.Key
$sourceValue = if ($null -eq $entry.Value) { '' } else { [string]$entry.Value }
if (-not $TargetMap.Contains($key)) {
$missingKeys += 1
continue
}
$targetValue = if ($null -eq $TargetMap[$key]) { '' } else { [string]$TargetMap[$key] }
if ([string]::IsNullOrWhiteSpace($targetValue)) {
$emptyKeys += 1
continue
}
if ($LanguageCode -ne 'en' -and $targetValue -ceq $sourceValue) {
if (Test-IntentionalSourceEqualValue -LanguageCode $LanguageCode -SourceValue $sourceValue -TargetValue $targetValue) {
$translatedKeys += 1
continue
}
$sourceEqualKeys += 1
continue
}
$translatedKeys += 1
}
$knownKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($key in $NeutralMap.Keys) {
[void]$knownKeys.Add([string]$key)
}
foreach ($key in $NeutralLegacyMap.Keys) {
[void]$knownKeys.Add([string]$key)
}
foreach ($key in $TargetMap.Keys) {
if (-not $knownKeys.Contains([string]$key)) {
$extraKeys += 1
}
}
$isKnownPlaceholder = Test-KnownPlaceholderLocale -LanguageCode $LanguageCode
$sourceEqualThreshold = if ($totalKeys -le 0) {
0
}
else {
[int][Math]::Max(
[double]($totalKeys - 5),
[Math]::Ceiling($totalKeys * 0.98)
)
}
$potentialFallbackRegression = $HasFile -and -not $isKnownPlaceholder -and $sourceEqualKeys -ge $sourceEqualThreshold -and $translatedKeys -le 5
$completion = Get-CompletionPercentage -Completed $translatedKeys -Total $totalKeys
$storedText = if ($null -eq $StoredPercentage) { '' } else { '{0}%' -f $StoredPercentage }
$delta = if ($null -eq $StoredPercentage) { $null } else { $completion - $StoredPercentage }
return [pscustomobject]@{
Code = $LanguageCode
Language = $LanguageName
HasFile = $HasFile
HasBoundary = $HasBoundary
TotalKeys = $totalKeys
ActiveKeys = $totalKeys
Legacy = $TargetLegacyMap.Count
Translated = $translatedKeys
Missing = $missingKeys
Empty = $emptyKeys
SourceEqual = $sourceEqualKeys
Untranslated = $missingKeys + $emptyKeys + $sourceEqualKeys
Extra = $extraKeys
Completion = '{0}%' -f $completion
CompletionValue = $completion
Stored = $storedText
StoredValue = $StoredPercentage
Delta = $delta
KnownPlaceholder = $isKnownPlaceholder
PotentialFallbackRegression = $potentialFallbackRegression
}
}
function Convert-RowsToMarkdown {
param(
[Parameter(Mandatory = $true)]
[object[]]$Rows
)
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('| Code | Language | Completion | Stored | Delta | Translated | Missing | Empty | Source Equal | Extra |')
$lines.Add('| :-- | :-- | --: | --: | --: | --: | --: | --: | --: | --: |')
foreach ($row in $Rows) {
$deltaText = if ($null -eq $row.Delta) { '' } elseif ($row.Delta -gt 0) { '+{0}' -f $row.Delta } else { [string]$row.Delta }
$storedText = if ([string]::IsNullOrWhiteSpace([string]$row.Stored)) { '' } else { [string]$row.Stored }
$lines.Add("| $($row.Code) | $($row.Language) | $($row.Completion) | $storedText | $deltaText | $($row.Translated) | $($row.Missing) | $($row.Empty) | $($row.SourceEqual) | $($row.Extra) |")
}
return ($lines -join [Environment]::NewLine)
}
function Write-OutputContent {
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Content
)
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
Write-Output $Content
return
}
$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$directory = Split-Path -Path $resolvedOutputPath -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($resolvedOutputPath, $Content + [Environment]::NewLine, $encoding)
Write-Output "Wrote translation status summary to: $resolvedOutputPath"
}
function Get-OverviewLines {
param(
[Parameter(Mandatory = $true)]
[object[]]$Rows
)
if ($Rows.Count -eq 0) {
return @('Languages: 0', 'Incomplete: 0', 'Fully translated: 0')
}
$incompleteCount = @($Rows | Where-Object { $_.Untranslated -gt 0 }).Count
$completeCount = @($Rows | Where-Object { $_.Untranslated -eq 0 }).Count
$totalUntranslated = ($Rows | Measure-Object -Property Untranslated -Sum).Sum
$totalMissing = ($Rows | Measure-Object -Property Missing -Sum).Sum
$totalEmpty = ($Rows | Measure-Object -Property Empty -Sum).Sum
$totalSourceEqual = ($Rows | Measure-Object -Property SourceEqual -Sum).Sum
$placeholderRows = @($Rows | Where-Object { $_.KnownPlaceholder })
$potentialRegressionRows = @($Rows | Where-Object { $_.PotentialFallbackRegression })
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add(('Languages: {0}' -f $Rows.Count))
$lines.Add(('Incomplete: {0}' -f $incompleteCount))
$lines.Add(('Fully translated: {0}' -f $completeCount))
$lines.Add(('Outstanding entries: {0} (missing {1}, empty {2}, source-equal {3})' -f $totalUntranslated, $totalMissing, $totalEmpty, $totalSourceEqual))
if ($placeholderRows.Count -gt 0) {
$lines.Add(('Known placeholders: {0}' -f (($placeholderRows | ForEach-Object { $_.Code }) -join ', ')))
}
if ($potentialRegressionRows.Count -gt 0) {
$lines.Add(('Potential fallback regressions: {0}' -f (($potentialRegressionRows | ForEach-Object { $_.Code }) -join ', ')))
}
return $lines.ToArray()
}
$languagesDirectory = Get-LanguagesDirectoryPath
$neutralPath = Join-Path $languagesDirectory 'lang_en.json'
if (-not (Test-Path -Path $neutralPath -PathType Leaf)) {
throw "Neutral language file not found: $neutralPath"
}
$neutralSections = Get-LanguageMapSections -Map (Read-LanguageMap -Path $neutralPath)
$neutralMap = $neutralSections.ActiveMap
$neutralLegacyMap = $neutralSections.LegacyMap
$languageReference = Get-LanguageReference
$storedPercentages = Get-TranslatedPercentages
$rows = New-Object System.Collections.Generic.List[object]
foreach ($entry in $languageReference.GetEnumerator()) {
$languageCode = [string]$entry.Key
if ($languageCode -eq 'default') {
continue
}
if (-not $IncludeEnglish.IsPresent -and $languageCode -eq 'en') {
continue
}
$languageFilePath = Join-Path $languagesDirectory ("lang_{0}.json" -f $languageCode)
$hasFile = Test-Path -Path $languageFilePath -PathType Leaf
$targetSections = if ($hasFile) { Get-LanguageMapSections -Map (Read-LanguageMap -Path $languageFilePath) } else { [pscustomobject]@{ HasBoundary = $false; FullMap = (New-OrderedStringMap); ActiveMap = (New-OrderedStringMap); LegacyMap = (New-OrderedStringMap) } }
$storedPercentage = if ($storedPercentages.Contains($languageCode)) { Get-PercentageNumber -Value $storedPercentages[$languageCode] } elseif ($languageCode -eq 'en') { 100 } else { $null }
$row = Get-TranslationStatusRow -LanguageCode $languageCode -LanguageName ([string]$entry.Value) -NeutralMap $neutralMap -NeutralLegacyMap $neutralLegacyMap -TargetMap $targetSections.FullMap -TargetLegacyMap $targetSections.LegacyMap -StoredPercentage $storedPercentage -HasFile:$hasFile -HasBoundary:$targetSections.HasBoundary
if ($OnlyIncomplete.IsPresent -and $row.Untranslated -eq 0) {
continue
}
$rows.Add($row)
}
$orderedRows = @(
$rows |
Sort-Object @{ Expression = 'CompletionValue'; Descending = $false }, @{ Expression = 'Code'; Descending = $false }
)
switch ($OutputFormat) {
'Json' {
$json = if ($orderedRows.Count -eq 0) {
'[]'
}
else {
$orderedRows | ConvertTo-Json -Depth 5
}
Write-OutputContent -Content $json
}
'Markdown' {
$overview = Get-OverviewLines -Rows $orderedRows
$markdownLines = @(
'## Translation Status Overview',
''
) + @($overview | ForEach-Object { '- ' + $_ }) + @(
'',
(Convert-RowsToMarkdown -Rows $orderedRows)
)
$markdown = $markdownLines -join [Environment]::NewLine
Write-OutputContent -Content $markdown
}
default {
$overview = Get-OverviewLines -Rows $orderedRows
$tableText = $orderedRows |
Select-Object Code, Language, Completion, Stored, Delta, Translated, Missing, Empty, SourceEqual, Extra |
Format-Table -AutoSize |
Out-String
$content = (@(
$overview
) + @(
'',
$tableText.TrimEnd()
)) -join [Environment]::NewLine
Write-OutputContent -Content $content
}
}
@@ -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
}
@@ -0,0 +1,39 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$BatchDir,
[Parameter(Mandatory = $true)]
[string]$OutputTranslatedPatch
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
$batchRoot = Get-FullPath -Path $BatchDir
$outputPath = Get-FullPath -Path $OutputTranslatedPatch
$manifestPath = Join-Path $batchRoot 'manifest.json'
if (-not (Test-Path -Path $manifestPath -PathType Leaf)) {
throw "Batch manifest not found: $manifestPath"
}
$manifest = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json
$mergedMap = New-OrderedStringMap
foreach ($batch in @($manifest.batches | Sort-Object batchNumber)) {
$translatedPath = Join-Path $batchRoot ([string]$batch.translated)
if (-not (Test-Path -Path $translatedPath -PathType Leaf)) {
throw "Translated batch file not found: $translatedPath"
}
$translatedMap = Read-OrderedJsonMap -Path $translatedPath
foreach ($entry in $translatedMap.GetEnumerator()) {
$mergedMap[[string]$entry.Key] = [string]$entry.Value
}
}
Write-OrderedJsonMap -Path $outputPath -Map $mergedMap
Write-Output "Merged $($manifest.batchCount) translation batch(es) into $outputPath"
@@ -0,0 +1,36 @@
[CmdletBinding()]
param(
[string]$RepositoryRoot,
[string]$EnglishFilePath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
. (Join-Path $PSScriptRoot 'Languages\TranslationSourceTools.ps1')
$resolvedRepositoryRoot = Resolve-TranslationSyncRepositoryRoot -RepositoryRoot $RepositoryRoot
$resolvedEnglishFilePath = Resolve-EnglishLanguageFilePath -ResolvedRepositoryRoot $resolvedRepositoryRoot -EnglishFilePath $EnglishFilePath
if (-not (Test-Path -Path $resolvedEnglishFilePath -PathType Leaf)) {
throw "English translation file not found: $resolvedEnglishFilePath"
}
$snapshot = Get-TranslationSourceSnapshot -RepositoryRoot $resolvedRepositoryRoot
$englishTranslations = Read-OrderedJsonMapPermissive -Path $resolvedEnglishFilePath
$extractedKeySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($key in $snapshot.KeyOrder) {
[void]$extractedKeySet.Add([string]$key)
}
$unusedKeys = New-Object System.Collections.Generic.List[string]
foreach ($entry in $englishTranslations.GetEnumerator()) {
$key = [string]$entry.Key
if (-not $extractedKeySet.Contains($key)) {
$unusedKeys.Add($key)
Write-Output "Unused key: $key"
}
}
Write-Output "Scan completed. Checked $(@($snapshot.SourceFiles).Count) file(s); found $($unusedKeys.Count) unused key(s)."
@@ -0,0 +1,92 @@
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$RepositoryRoot,
[string]$EnglishFilePath,
[string]$LegacyRef = 'HEAD~1',
[switch]$CheckOnly
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
. (Join-Path $PSScriptRoot 'Languages\TranslationSourceTools.ps1')
$resolvedRepositoryRoot = Resolve-TranslationSyncRepositoryRoot -RepositoryRoot $RepositoryRoot
$resolvedEnglishFilePath = Resolve-EnglishLanguageFilePath -ResolvedRepositoryRoot $resolvedRepositoryRoot -EnglishFilePath $EnglishFilePath
if (-not (Test-Path -Path $resolvedEnglishFilePath -PathType Leaf)) {
throw "English translation file not found: $resolvedEnglishFilePath"
}
$relativeEnglishPath = Get-RepoRelativePath -RepositoryRoot $resolvedRepositoryRoot -FilePath $resolvedEnglishFilePath
$gitObject = '{0}:{1}' -f $LegacyRef, $relativeEnglishPath
$legacyContent = & git -C $resolvedRepositoryRoot show $gitObject 2>$null
if ($LASTEXITCODE -ne 0) {
throw "Unable to read '$relativeEnglishPath' from git ref '$LegacyRef'."
}
$snapshot = Get-TranslationSourceSnapshot -RepositoryRoot $resolvedRepositoryRoot
$currentMap = Read-OrderedJsonMap -Path $resolvedEnglishFilePath
$legacySourceMap = Convert-JsonContentToOrderedMap -Content ([string]::Join([Environment]::NewLine, @($legacyContent))) -Path $gitObject -DetectDuplicates
$extractedKeySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($key in $snapshot.KeyOrder) {
[void]$extractedKeySet.Add([string]$key)
}
$mergedLegacyMap = New-OrderedStringMap
foreach ($entry in $legacySourceMap.GetEnumerator()) {
$key = [string]$entry.Key
if ($key -ceq (Get-TranslationLegacyBoundaryKey)) {
continue
}
if (-not $extractedKeySet.Contains($key)) {
$mergedLegacyMap[$key] = if ($currentMap.Contains($key)) { [string]$currentMap[$key] } else { [string]$entry.Value }
}
}
$currentSections = Split-TranslationMapAtBoundary -Map $currentMap
foreach ($entry in $currentSections.LegacyMap.GetEnumerator()) {
$key = [string]$entry.Key
if (-not $mergedLegacyMap.Contains($key)) {
$mergedLegacyMap[$key] = [string]$entry.Value
}
}
$updatedActiveMap = New-OrderedStringMap
foreach ($key in $snapshot.KeyOrder) {
$updatedActiveMap[$key] = if ($currentMap.Contains($key)) { [string]$currentMap[$key] } else { $key }
}
$updatedMap = Join-TranslationMapWithBoundary -ActiveMap $updatedActiveMap -LegacyMap $mergedLegacyMap -IncludeBoundary
$hasChanges = -not (Test-OrderedStringMapsEqual -Left $currentMap -Right $updatedMap)
Write-Output 'English legacy boundary summary'
Write-Output "Repository root: $resolvedRepositoryRoot"
Write-Output "English file: $resolvedEnglishFilePath"
Write-Output "Legacy ref: $LegacyRef"
Write-Output "Active keys: $($updatedActiveMap.Count)"
Write-Output "Legacy keys restored: $($mergedLegacyMap.Count)"
Write-Output "Needs update: $hasChanges"
if ($CheckOnly) {
if ($hasChanges) {
throw 'English legacy boundary layout is not in the expected state.'
}
return
}
if (-not $hasChanges) {
Write-Output ''
Write-Output 'The English file already matches the expected boundary layout.'
return
}
if ($PSCmdlet.ShouldProcess($resolvedEnglishFilePath, 'Populate the English legacy translation tail')) {
Write-OrderedJsonMap -Path $resolvedEnglishFilePath -Map $updatedMap
Write-Output ''
Write-Output 'The English legacy boundary layout was updated successfully.'
}
@@ -0,0 +1,138 @@
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$RepositoryRoot,
[string]$EnglishFilePath,
[string]$LanguagesDirectory,
[string]$InventoryOutputPath,
[switch]$IncludeEnglish,
[switch]$CheckOnly
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
. (Join-Path $PSScriptRoot 'Languages\TranslationSourceTools.ps1')
$resolvedRepositoryRoot = Resolve-TranslationSyncRepositoryRoot -RepositoryRoot $RepositoryRoot
$resolvedEnglishFilePath = Resolve-EnglishLanguageFilePath -ResolvedRepositoryRoot $resolvedRepositoryRoot -EnglishFilePath $EnglishFilePath
$resolvedLanguagesDirectory = if ([string]::IsNullOrWhiteSpace($LanguagesDirectory)) {
Split-Path -Path $resolvedEnglishFilePath -Parent
}
else {
Get-FullPath -Path $LanguagesDirectory
}
if (-not (Test-Path -Path $resolvedLanguagesDirectory -PathType Container)) {
throw "Languages directory not found: $resolvedLanguagesDirectory"
}
$englishMap = Read-OrderedJsonMap -Path $resolvedEnglishFilePath
$englishSections = Split-TranslationMapAtBoundary -Map $englishMap
if (-not $englishSections.HasBoundary) {
Write-Output 'The English translation file does not contain a legacy boundary marker. No reordering needed.'
return
}
$englishActiveOrder = @($englishSections.ActiveMap.Keys)
$englishLegacyOrder = @($englishSections.LegacyMap.Keys)
$englishKeySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($key in $englishActiveOrder) {
[void]$englishKeySet.Add([string]$key)
}
foreach ($key in $englishLegacyOrder) {
[void]$englishKeySet.Add([string]$key)
}
$filesToProcess = Get-ChildItem -Path $resolvedLanguagesDirectory -Filter 'lang_*.json' | Sort-Object Name
if (-not $IncludeEnglish) {
$filesToProcess = @($filesToProcess | Where-Object { $_.FullName -ine $resolvedEnglishFilePath })
}
$changedFiles = New-Object System.Collections.Generic.List[string]
$reports = New-Object System.Collections.Generic.List[object]
foreach ($languageFile in $filesToProcess) {
$languageMap = Read-OrderedJsonMapPermissive -Path $languageFile.FullName
$activeOutput = New-OrderedStringMap
foreach ($key in $englishActiveOrder) {
if ($languageMap.Contains($key)) {
$activeOutput[$key] = [string]$languageMap[$key]
}
}
$legacyOutput = New-OrderedStringMap
foreach ($key in $englishLegacyOrder) {
if ($languageMap.Contains($key)) {
$legacyOutput[$key] = [string]$languageMap[$key]
}
}
$unmappedKeys = New-Object System.Collections.Generic.List[string]
foreach ($entry in $languageMap.GetEnumerator()) {
$key = [string]$entry.Key
if ($key -ceq (Get-TranslationLegacyBoundaryKey)) {
continue
}
if (-not $englishKeySet.Contains($key)) {
$legacyOutput[$key] = [string]$entry.Value
$unmappedKeys.Add($key)
}
}
$updatedMap = Join-TranslationMapWithBoundary -ActiveMap $activeOutput -LegacyMap $legacyOutput -IncludeBoundary
$hasChanges = -not (Test-OrderedStringMapsEqual -Left $languageMap -Right $updatedMap)
if ($hasChanges) {
$changedFiles.Add((Get-RepoRelativePath -RepositoryRoot $resolvedRepositoryRoot -FilePath $languageFile.FullName))
if (-not $CheckOnly -and $PSCmdlet.ShouldProcess($languageFile.FullName, 'Reorder translation file to the English legacy boundary layout')) {
Write-OrderedJsonMap -Path $languageFile.FullName -Map $updatedMap
}
}
$reports.Add([pscustomobject]@{
languageFile = (Get-RepoRelativePath -RepositoryRoot $resolvedRepositoryRoot -FilePath $languageFile.FullName)
changed = $hasChanges
activeKeyCount = $activeOutput.Count
legacyKeyCount = $legacyOutput.Count
unmappedKeyCount = $unmappedKeys.Count
unmappedKeys = $unmappedKeys.ToArray()
})
}
if (-not [string]::IsNullOrWhiteSpace($InventoryOutputPath)) {
$resolvedInventoryOutputPath = Get-FullPath -Path $InventoryOutputPath
$report = [pscustomobject]@{
generatedAt = (Get-Date).ToString('o')
repositoryRoot = $resolvedRepositoryRoot
englishFile = (Get-RepoRelativePath -RepositoryRoot $resolvedRepositoryRoot -FilePath $resolvedEnglishFilePath)
languagesDirectory = (Get-RepoRelativePath -RepositoryRoot $resolvedRepositoryRoot -FilePath $resolvedLanguagesDirectory)
includeEnglish = [bool]$IncludeEnglish
changedFileCount = $changedFiles.Count
changedFiles = $changedFiles.ToArray()
files = $reports.ToArray()
}
New-Utf8File -Path $resolvedInventoryOutputPath -Content ($report | ConvertTo-Json -Depth 6)
}
Write-Output 'Translation boundary reorder summary'
Write-Output "Repository root: $resolvedRepositoryRoot"
Write-Output "Languages directory: $resolvedLanguagesDirectory"
Write-Output "Include English: $([bool]$IncludeEnglish)"
Write-Output "Files processed: $(@($filesToProcess).Count)"
Write-Output "Files changed: $($changedFiles.Count)"
if ($changedFiles.Count -gt 0) {
Write-Output ''
Write-Output 'Changed files:'
foreach ($path in $changedFiles) {
Write-Output (" * {0}" -f $path)
}
}
if ($CheckOnly -and $changedFiles.Count -gt 0) {
throw 'One or more translation files are not aligned to the English legacy boundary layout.'
}
@@ -0,0 +1,126 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$SourcePatch,
[Parameter(Mandatory = $true)]
[string]$OutputDir,
[string]$TranslatedPatch,
[string]$ReferencePatch,
[ValidateRange(1, 100)]
[int]$BatchSize = 100,
[switch]$Clean
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
function New-BatchFileName {
param(
[Parameter(Mandatory = $true)]
[int]$BatchNumber,
[Parameter(Mandatory = $true)]
[string]$Kind
)
return ('batch.{0:D2}.{1}.json' -f $BatchNumber, $Kind)
}
$sourcePatchPath = Get-FullPath -Path $SourcePatch
$outputRoot = Get-FullPath -Path $OutputDir
$translatedPatchPath = if ([string]::IsNullOrWhiteSpace($TranslatedPatch)) { $null } else { Get-FullPath -Path $TranslatedPatch }
$referencePatchPath = if ([string]::IsNullOrWhiteSpace($ReferencePatch)) { $null } else { Get-FullPath -Path $ReferencePatch }
if (-not (Test-Path -Path $sourcePatchPath -PathType Leaf)) {
throw "Source patch not found: $sourcePatchPath"
}
if ($null -ne $translatedPatchPath -and -not (Test-Path -Path $translatedPatchPath -PathType Leaf)) {
throw "Translated patch not found: $translatedPatchPath"
}
if ($null -ne $referencePatchPath -and -not (Test-Path -Path $referencePatchPath -PathType Leaf)) {
throw "Reference patch not found: $referencePatchPath"
}
if ($Clean -and (Test-Path -Path $outputRoot)) {
Remove-Item -Path $outputRoot -Recurse -Force
}
New-Item -Path $outputRoot -ItemType Directory -Force | Out-Null
$sourceMap = Read-OrderedJsonMap -Path $sourcePatchPath
$translatedMap = if ($null -eq $translatedPatchPath) { New-OrderedStringMap } else { Read-OrderedJsonMap -Path $translatedPatchPath }
$referenceMap = if ($null -eq $referencePatchPath) { New-OrderedStringMap } else { Read-OrderedJsonMap -Path $referencePatchPath }
$entries = @($sourceMap.GetEnumerator())
$batchCount = if ($entries.Count -eq 0) { 0 } else { [int][Math]::Ceiling($entries.Count / $BatchSize) }
$manifestBatches = New-Object System.Collections.Generic.List[object]
for ($batchIndex = 0; $batchIndex -lt $batchCount; $batchIndex += 1) {
$batchNumber = $batchIndex + 1
$startIndex = $batchIndex * $BatchSize
$endIndexExclusive = [Math]::Min($startIndex + $BatchSize, $entries.Count)
$batchSourceMap = New-OrderedStringMap
$batchTranslatedMap = New-OrderedStringMap
$batchReferenceMap = New-OrderedStringMap
$batchKeys = New-Object System.Collections.Generic.List[string]
for ($entryIndex = $startIndex; $entryIndex -lt $endIndexExclusive; $entryIndex += 1) {
$entry = $entries[$entryIndex]
$key = [string]$entry.Key
$value = [string]$entry.Value
$batchSourceMap[$key] = $value
$batchKeys.Add($key)
if ($translatedMap.Contains($key)) {
$batchTranslatedMap[$key] = [string]$translatedMap[$key]
}
if ($referenceMap.Contains($key)) {
$batchReferenceMap[$key] = [string]$referenceMap[$key]
}
}
$sourceFileName = New-BatchFileName -BatchNumber $batchNumber -Kind 'source'
$translatedFileName = New-BatchFileName -BatchNumber $batchNumber -Kind 'translated'
$referenceFileName = New-BatchFileName -BatchNumber $batchNumber -Kind 'reference'
Write-OrderedJsonMap -Path (Join-Path $outputRoot $sourceFileName) -Map $batchSourceMap
Write-OrderedJsonMap -Path (Join-Path $outputRoot $translatedFileName) -Map $batchTranslatedMap
Write-OrderedJsonMap -Path (Join-Path $outputRoot $referenceFileName) -Map $batchReferenceMap
$manifestBatches.Add([pscustomobject]@{
batchNumber = $batchNumber
keyCount = $batchSourceMap.Count
source = $sourceFileName
translated = $translatedFileName
reference = $referenceFileName
firstKey = if ($batchKeys.Count -gt 0) { $batchKeys[0] } else { $null }
lastKey = if ($batchKeys.Count -gt 0) { $batchKeys[$batchKeys.Count - 1] } else { $null }
}) | Out-Null
}
$manifest = [pscustomobject]@{
generatedAt = (Get-Date).ToString('o')
sourcePatch = $sourcePatchPath
translatedPatch = $translatedPatchPath
referencePatch = $referencePatchPath
batchSize = $BatchSize
totalKeys = $sourceMap.Count
batchCount = $batchCount
batches = $manifestBatches.ToArray()
}
New-Utf8File -Path (Join-Path $outputRoot 'manifest.json') -Content (($manifest | ConvertTo-Json -Depth 6) + "`r`n")
Write-Output "Created $batchCount translation batch(es) in $outputRoot"
@@ -0,0 +1,346 @@
[CmdletBinding(DefaultParameterSetName = 'Selected', SupportsShouldProcess = $true)]
param(
[Parameter(ParameterSetName = 'Selected', Mandatory = $true)]
[string[]]$LanguageCodes,
[Parameter(ParameterSetName = 'All')]
[switch]$AllLanguages,
[switch]$CheckOnly,
[switch]$UpdateTranslationDoc,
[switch]$UpdateReadme,
[string]$ReadmePath = (Join-Path $PSScriptRoot '..\..\README.md'),
[string]$TranslationDocPath = (Join-Path $PSScriptRoot '..\..\TRANSLATION.md'),
[string]$TranslatorsPath = (Join-Path $PSScriptRoot '..\..\src\Languages\Data\Translators.json'),
[string]$TranslatedPercentagesPath = (Join-Path $PSScriptRoot '..\..\src\Languages\Data\TranslatedPercentages.json')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'Languages\LanguageData.psm1') -Force
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
$script:TranslatorCreditsKey = '0 0 0 Contributors, please add your names/usernames separated by comas (for credit purposes). DO NOT Translate this entry'
function Read-JsonObject {
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 ConvertTo-NormalizedJson {
param(
[Parameter(Mandatory = $true)]
[AllowNull()]
[object]$Value
)
return ($Value | ConvertTo-Json -Depth 10)
}
function ConvertTo-TranslatorMetadataJson {
param(
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$Map
)
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('{')
$rootEntries = @($Map.GetEnumerator())
for ($index = 0; $index -lt $rootEntries.Count; $index++) {
$entry = $rootEntries[$index]
$key = ConvertTo-JsonStringLiteral -Value ([string]$entry.Key)
$translators = @($entry.Value)
$isLastRootEntry = $index -eq ($rootEntries.Count - 1)
if ($translators.Count -eq 0) {
$line = ' {0}: []' -f $key
if (-not $isLastRootEntry) {
$line += ','
}
$lines.Add($line)
continue
}
$lines.Add(' {0}: [' -f $key)
for ($translatorIndex = 0; $translatorIndex -lt $translators.Count; $translatorIndex++) {
$translator = $translators[$translatorIndex]
$isLastTranslator = $translatorIndex -eq ($translators.Count - 1)
$name = ConvertTo-JsonStringLiteral -Value ([string]$translator['name'])
$link = ConvertTo-JsonStringLiteral -Value ([string]$translator['link'])
$lines.Add(' {')
$lines.Add(' "name": {0},' -f $name)
$lines.Add(' "link": {0}' -f $link)
$translatorClosing = ' }'
if (-not $isLastTranslator) {
$translatorClosing += ','
}
$lines.Add($translatorClosing)
}
$rootClosing = ' ]'
if (-not $isLastRootEntry) {
$rootClosing += ','
}
$lines.Add($rootClosing)
}
$lines.Add('}')
return (($lines -join "`r`n") + "`r`n")
}
function Get-RequestedLanguageCodes {
$languageReference = Get-LanguageReference
$codesToUpdate = @()
if ($AllLanguages.IsPresent) {
$codesToUpdate = @(
$languageReference.Keys |
Where-Object { $_ -ne 'default' } |
Sort-Object
)
}
else {
$codesToUpdate = @(
$LanguageCodes |
ForEach-Object { $_.Trim() } |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
Sort-Object -Unique
)
}
if ($codesToUpdate.Count -eq 0) {
throw 'No language codes were provided.'
}
foreach ($code in $codesToUpdate) {
if (-not $languageReference.Contains($code)) {
throw "Unknown language code '$code'."
}
}
return $codesToUpdate
}
function Get-ExpectedTranslationPercentages {
param(
[Parameter(Mandatory = $true)]
[string[]]$CodesToUpdate,
[Parameter(Mandatory = $true)]
[string]$Path
)
$includeEnglish = $AllLanguages.IsPresent -or ($CodesToUpdate -contains 'en')
$statusJson = & (Join-Path $PSScriptRoot 'Get-TranslationStatus.ps1') -OutputFormat Json -IncludeEnglish:$includeEnglish
$statusRows = $statusJson | ConvertFrom-Json -AsHashtable
if ($null -eq $statusRows) {
throw 'Could not load translation status data.'
}
$statusByCode = @{}
foreach ($row in $statusRows) {
$statusByCode[[string]$row.Code] = $row
}
$storedPercentages = Read-JsonObject -Path $Path
$updatedPercentages = [ordered]@{}
foreach ($entry in $storedPercentages.GetEnumerator()) {
$updatedPercentages[[string]$entry.Key] = [string]$entry.Value
}
foreach ($code in $CodesToUpdate) {
if (-not $statusByCode.ContainsKey($code)) {
throw "Language code '$code' was not found in the computed translation status."
}
$updatedPercentages[$code] = [string]$statusByCode[$code].Completion
}
return $updatedPercentages
}
function Get-ExpectedTranslatorMetadata {
param(
[Parameter(Mandatory = $true)]
[string[]]$CodesToUpdate,
[Parameter(Mandatory = $true)]
[string]$Path
)
$languagesDirectory = Get-LanguagesDirectoryPath
$englishLanguageFilePath = Join-Path $languagesDirectory 'lang_en.json'
$storedTranslators = Read-JsonObject -Path $Path
$updatedTranslators = [ordered]@{}
foreach ($entry in $storedTranslators.GetEnumerator()) {
$updatedTranslators[[string]$entry.Key] = @($entry.Value)
}
$englishLanguageMap = Read-OrderedJsonMap -Path $englishLanguageFilePath
$hasCreditsKey = $englishLanguageMap.Contains($script:TranslatorCreditsKey)
foreach ($code in $CodesToUpdate) {
$languageFilePath = Join-Path $languagesDirectory ("lang_{0}.json" -f $code)
if (-not (Test-Path -Path $languageFilePath -PathType Leaf)) {
throw "Language file not found: $languageFilePath"
}
$languageMap = Read-OrderedJsonMap -Path $languageFilePath
if (-not $hasCreditsKey -or -not $languageMap.Contains($script:TranslatorCreditsKey)) {
if (-not $updatedTranslators.Contains($code)) {
$updatedTranslators[$code] = @()
}
continue
}
$englishCreditsSignature = Get-TranslatorCreditsSignature -Credits ([string]$englishLanguageMap[$script:TranslatorCreditsKey])
$credits = [string]$languageMap[$script:TranslatorCreditsKey]
$creditsSignature = Get-TranslatorCreditsSignature -Credits $credits
$shouldPreserveExisting = $code -ne 'en' -and $creditsSignature -eq $englishCreditsSignature
if ($shouldPreserveExisting) {
if (-not $updatedTranslators.Contains($code)) {
$updatedTranslators[$code] = @()
}
Write-Warning "Preserving existing translator metadata for '$code' because its locale credits currently match the English fallback credits."
continue
}
$updatedTranslators[$code] = @(ConvertTo-TranslatorMetadataEntries -Credits $credits)
}
return $updatedTranslators
}
function Get-TranslatorCreditsSignature {
param(
[AllowNull()]
[string]$Credits
)
$entries = @(Get-TranslatorsFromCredits -Credits $Credits)
if ($entries.Count -eq 0) {
return ''
}
return @(
$entries |
ForEach-Object { ([string]$_.name).Trim().ToLowerInvariant() }
) -join "`n"
}
function ConvertTo-TranslatorMetadataEntries {
param(
[AllowNull()]
[string]$Credits
)
return @(
Get-TranslatorsFromCredits -Credits $Credits |
ForEach-Object {
[ordered]@{
name = [string]$_.name
link = [string]$_.link
}
}
)
}
function Write-TranslationDocumentation {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
New-Utf8File -Path $Path -Content (Get-TranslationDocumentationMarkdown)
}
$shouldUpdateTranslationDoc = $UpdateTranslationDoc.IsPresent
if ($UpdateReadme.IsPresent) {
Write-Warning 'The -UpdateReadme switch is deprecated. Updating TRANSLATION.md instead.'
$shouldUpdateTranslationDoc = $true
}
$codesToUpdate = Get-RequestedLanguageCodes
$expectedPercentages = Get-ExpectedTranslationPercentages -CodesToUpdate $codesToUpdate -Path $TranslatedPercentagesPath
$expectedTranslators = Get-ExpectedTranslatorMetadata -CodesToUpdate $codesToUpdate -Path $TranslatorsPath
$actualPercentages = Read-JsonObject -Path $TranslatedPercentagesPath
$actualTranslators = Read-JsonObject -Path $TranslatorsPath
$driftedFiles = New-Object System.Collections.Generic.List[string]
if ((ConvertTo-NormalizedJson -Value $actualPercentages) -ne (ConvertTo-NormalizedJson -Value $expectedPercentages)) {
$driftedFiles.Add((Get-TranslatedPercentagesJsonPath))
}
if ((ConvertTo-NormalizedJson -Value $actualTranslators) -ne (ConvertTo-NormalizedJson -Value $expectedTranslators)) {
$driftedFiles.Add((Get-TranslatorsJsonPath))
}
Write-Output ('Translation metadata scope: ' + ($codesToUpdate -join ', '))
Write-Output ('Metadata drift detected: ' + $driftedFiles.Count)
if ($driftedFiles.Count -gt 0) {
foreach ($path in $driftedFiles) {
Write-Output (' * ' + $path)
}
}
if ($CheckOnly.IsPresent) {
if ($driftedFiles.Count -gt 0) {
throw 'Translation metadata is out of date. Run scripts/translation/Sync-TranslationMetadata.ps1 to refresh it.'
}
Write-Output 'Translation metadata is up to date.'
return
}
if ($driftedFiles.Contains((Get-TranslatedPercentagesJsonPath)) -and $PSCmdlet.ShouldProcess($TranslatedPercentagesPath, 'Update translated percentages metadata')) {
New-Utf8File -Path $TranslatedPercentagesPath -Content ((ConvertTo-NormalizedJson -Value $expectedPercentages) + "`r`n")
}
if ($driftedFiles.Contains((Get-TranslatorsJsonPath)) -and $PSCmdlet.ShouldProcess($TranslatorsPath, 'Update translator metadata')) {
New-Utf8File -Path $TranslatorsPath -Content (ConvertTo-TranslatorMetadataJson -Map $expectedTranslators)
}
if ($shouldUpdateTranslationDoc -and $PSCmdlet.ShouldProcess($TranslationDocPath, 'Update translation documentation')) {
Write-TranslationDocumentation -Path $TranslationDocPath
}
Write-Output 'Translation metadata sync completed.'
@@ -0,0 +1,212 @@
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$RepositoryRoot,
[string]$EnglishFilePath,
[string]$InventoryOutputPath,
[switch]$UseLegacyBoundary,
[switch]$CheckOnly
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
. (Join-Path $PSScriptRoot 'Languages\TranslationSourceTools.ps1')
$resolvedRepositoryRoot = Resolve-TranslationSyncRepositoryRoot -RepositoryRoot $RepositoryRoot
$resolvedEnglishFilePath = Resolve-EnglishLanguageFilePath -ResolvedRepositoryRoot $resolvedRepositoryRoot -EnglishFilePath $EnglishFilePath
if (-not (Test-Path -Path $resolvedEnglishFilePath -PathType Leaf)) {
throw "English translation file not found: $resolvedEnglishFilePath"
}
$snapshot = Get-TranslationSourceSnapshot -RepositoryRoot $resolvedRepositoryRoot
$currentMap = Read-OrderedJsonMapPermissive -Path $resolvedEnglishFilePath
$currentSections = Split-TranslationMapAtBoundary -Map $currentMap
$preserveLegacyBoundary = $UseLegacyBoundary -or $currentSections.HasBoundary
$extractedKeySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($key in $snapshot.KeyOrder) {
[void]$extractedKeySet.Add([string]$key)
}
$currentKeySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($entry in $currentMap.GetEnumerator()) {
$key = [string]$entry.Key
if ($key -ceq (Get-TranslationLegacyBoundaryKey)) {
continue
}
[void]$currentKeySet.Add($key)
}
$missingKeys = New-Object System.Collections.Generic.List[string]
foreach ($key in $snapshot.KeyOrder) {
if (-not $currentKeySet.Contains($key)) {
$missingKeys.Add($key)
}
}
$unusedKeys = New-Object System.Collections.Generic.List[string]
foreach ($entry in $currentMap.GetEnumerator()) {
$key = [string]$entry.Key
if ($key -ceq (Get-TranslationLegacyBoundaryKey)) {
continue
}
if (-not $extractedKeySet.Contains($key)) {
$unusedKeys.Add($key)
}
}
$preservedLegacyMap = New-OrderedStringMap
$misplacedLegacyKeys = New-Object System.Collections.Generic.List[string]
foreach ($entry in $currentSections.ActiveMap.GetEnumerator()) {
$key = [string]$entry.Key
if (-not $extractedKeySet.Contains($key)) {
$misplacedLegacyKeys.Add($key)
}
}
$activeKeysBelowBoundary = New-Object System.Collections.Generic.List[string]
foreach ($entry in $currentSections.LegacyMap.GetEnumerator()) {
$key = [string]$entry.Key
if ($extractedKeySet.Contains($key)) {
$activeKeysBelowBoundary.Add($key)
}
}
foreach ($entry in $currentMap.GetEnumerator()) {
$key = [string]$entry.Key
if ($key -ceq (Get-TranslationLegacyBoundaryKey)) {
continue
}
if (-not $extractedKeySet.Contains($key)) {
$preservedLegacyMap[$key] = [string]$entry.Value
}
}
$syncedActiveMap = New-OrderedStringMap
foreach ($key in $snapshot.KeyOrder) {
$syncedActiveMap[$key] = if ($currentKeySet.Contains($key)) { [string]$currentMap[$key] } else { $key }
}
$syncedMap = if ($preserveLegacyBoundary) {
Join-TranslationMapWithBoundary -ActiveMap $syncedActiveMap -LegacyMap $preservedLegacyMap -IncludeBoundary
}
else {
$syncedActiveMap
}
$hasChanges = -not (Test-OrderedStringMapsEqual -Left $currentMap -Right $syncedMap)
if (-not [string]::IsNullOrWhiteSpace($InventoryOutputPath)) {
$resolvedInventoryOutputPath = Get-FullPath -Path $InventoryOutputPath
$inventory = [pscustomobject]@{
generatedAt = (Get-Date).ToString('o')
repositoryRoot = $resolvedRepositoryRoot
englishFile = (Get-RepoRelativePath -RepositoryRoot $resolvedRepositoryRoot -FilePath $resolvedEnglishFilePath)
legacyBoundaryEnabled = $preserveLegacyBoundary
legacyBoundaryPresent = $currentSections.HasBoundary
legacyBoundaryKey = Get-TranslationLegacyBoundaryKey
scannedSourceFileCount = @($snapshot.SourceFiles).Count
extractedKeyCount = @($snapshot.KeyOrder).Count
activeKeyCount = $syncedActiveMap.Count
legacyKeyCount = $preservedLegacyMap.Count
missingKeyCount = $missingKeys.Count
unusedKeyCount = $unusedKeys.Count
misplacedLegacyKeyCount = $misplacedLegacyKeys.Count
activeKeysBelowBoundaryCount = $activeKeysBelowBoundary.Count
warningCount = @($snapshot.Warnings).Count
missingKeys = $missingKeys.ToArray()
unusedKeys = $unusedKeys.ToArray()
legacyKeys = @($preservedLegacyMap.Keys)
misplacedLegacyKeys = $misplacedLegacyKeys.ToArray()
activeKeysBelowBoundary = $activeKeysBelowBoundary.ToArray()
warnings = @($snapshot.Warnings | Sort-Object Path, Line, Type | ForEach-Object {
[pscustomobject]@{
Path = $_.Path
Line = $_.Line
Type = $_.Type
Message = $_.Message
}
})
}
New-Utf8File -Path $resolvedInventoryOutputPath -Content ($inventory | ConvertTo-Json -Depth 6)
}
Write-Output "Translation source synchronization summary"
Write-Output "Repository root: $resolvedRepositoryRoot"
Write-Output "English file: $resolvedEnglishFilePath"
Write-Output "Legacy boundary enabled: $preserveLegacyBoundary"
Write-Output "Legacy boundary present: $($currentSections.HasBoundary)"
Write-Output "Scanned source files: $(@($snapshot.SourceFiles).Count)"
Write-Output "Extracted keys: $(@($snapshot.KeyOrder).Count)"
Write-Output "Missing English keys: $($missingKeys.Count)"
Write-Output "Unused English keys: $($unusedKeys.Count)"
Write-Output "Legacy English keys: $($preservedLegacyMap.Count)"
Write-Output "Misplaced legacy keys: $($misplacedLegacyKeys.Count)"
Write-Output "Active keys below boundary: $($activeKeysBelowBoundary.Count)"
Write-Output "Warnings: $(@($snapshot.Warnings).Count)"
if ($missingKeys.Count -gt 0) {
Write-Output ''
Write-Output 'Missing keys to add:'
foreach ($key in $missingKeys) {
Write-Output (" + {0}" -f $key)
}
}
if ((-not $preserveLegacyBoundary) -and $unusedKeys.Count -gt 0) {
Write-Output ''
Write-Output 'Unused keys to remove:'
foreach ($key in $unusedKeys) {
Write-Output (" - {0}" -f $key)
}
}
if ($misplacedLegacyKeys.Count -gt 0) {
Write-Output ''
Write-Output 'Legacy keys that should move below the boundary:'
foreach ($key in $misplacedLegacyKeys) {
Write-Output (" v {0}" -f $key)
}
}
if ($activeKeysBelowBoundary.Count -gt 0) {
Write-Output ''
Write-Output 'Active keys currently below the boundary:'
foreach ($key in $activeKeysBelowBoundary) {
Write-Output (" ^ {0}" -f $key)
}
}
if (@($snapshot.Warnings).Count -gt 0) {
Write-Output ''
Write-Output 'Warnings:'
foreach ($warning in $snapshot.Warnings | Sort-Object Path, Line, Type) {
Write-Output (" ! {0}:{1} [{2}] {3}" -f $warning.Path, $warning.Line, $warning.Type, $warning.Message)
}
}
if ($CheckOnly) {
if ($hasChanges) {
throw 'lang_en.json is out of sync with extracted translation sources.'
}
return
}
if (-not $hasChanges) {
Write-Output ''
Write-Output 'lang_en.json is already synchronized with extracted source usage.'
return
}
if ($PSCmdlet.ShouldProcess($resolvedEnglishFilePath, 'Synchronize English translation keys from source usage')) {
Write-OrderedJsonMap -Path $resolvedEnglishFilePath -Map $syncedMap
Write-Output ''
Write-Output 'lang_en.json was synchronized successfully.'
}
@@ -0,0 +1,126 @@
[CmdletBinding(DefaultParameterSetName = 'Selected')]
param(
[Parameter(ParameterSetName = 'Selected', Mandatory = $true)]
[string[]]$LanguageCodes,
[Parameter(ParameterSetName = 'All')]
[switch]$AllLanguages,
[switch]$UpdateTranslationDoc,
[switch]$UpdateReadme,
[string]$ReadmePath = (Join-Path $PSScriptRoot '..\..\README.md'),
[string]$TranslationDocPath = (Join-Path $PSScriptRoot '..\..\TRANSLATION.md'),
[string]$TranslatedPercentagesPath = (Join-Path $PSScriptRoot '..\..\src\Languages\Data\TranslatedPercentages.json')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'Languages\LanguageData.psm1') -Force
function Read-JsonObject {
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 Write-Utf8File {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Content
)
$encoding = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
}
function Write-TranslationDocumentation {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$encoding = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($Path, (Get-TranslationDocumentationMarkdown), $encoding)
}
$shouldUpdateTranslationDoc = $UpdateTranslationDoc.IsPresent
if ($UpdateReadme.IsPresent) {
Write-Warning 'The -UpdateReadme switch is deprecated. Updating TRANSLATION.md instead.'
$shouldUpdateTranslationDoc = $true
}
$statusJson = & (Join-Path $PSScriptRoot 'Get-TranslationStatus.ps1') -OutputFormat Json
$statusRows = $statusJson | ConvertFrom-Json -AsHashtable
if ($null -eq $statusRows) {
throw 'Could not load translation status data.'
}
$statusByCode = @{}
foreach ($row in $statusRows) {
$statusByCode[[string]$row.Code] = $row
}
$codesToUpdate = @()
if ($AllLanguages.IsPresent) {
$codesToUpdate = @($statusByCode.Keys | Sort-Object)
}
else {
$codesToUpdate = @($LanguageCodes | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
}
if ($codesToUpdate.Count -eq 0) {
throw 'No language codes were provided.'
}
$storedPercentages = Read-JsonObject -Path $TranslatedPercentagesPath
$updatedPercentages = [ordered]@{}
foreach ($entry in $storedPercentages.GetEnumerator()) {
$updatedPercentages[[string]$entry.Key] = [string]$entry.Value
}
foreach ($code in $codesToUpdate) {
if (-not $statusByCode.ContainsKey($code)) {
throw "Language code '$code' was not found in the computed translation status."
}
$updatedPercentages[$code] = [string]$statusByCode[$code].Completion
}
$percentagesJson = ($updatedPercentages | ConvertTo-Json -Depth 5)
Write-Utf8File -Path $TranslatedPercentagesPath -Content $percentagesJson
if ($shouldUpdateTranslationDoc) {
Write-TranslationDocumentation -Path $TranslationDocPath
}
Write-Output ('Updated translation status metadata for: ' + ($codesToUpdate -join ', '))
@@ -0,0 +1,143 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Languages\TranslationJsonTools.ps1')
. (Join-Path $PSScriptRoot 'Languages\TranslationSourceTools.ps1')
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
$syncScript = Join-Path $repoRoot 'scripts\translation\Sync-TranslationSources.ps1'
if (-not (Test-Path -Path $syncScript -PathType Leaf)) {
throw "Sync script not found: $syncScript"
}
$tempRoot = Join-Path $env:TEMP ('translation-source-sync-{0}' -f [System.Guid]::NewGuid().ToString('N'))
try {
$sourceDir = Join-Path $tempRoot 'src\Sample'
$languageDir = Join-Path $tempRoot 'src\Languages'
New-Item -Path $sourceDir -ItemType Directory -Force | Out-Null
New-Item -Path $languageDir -ItemType Directory -Force | Out-Null
New-Utf8File -Path (Join-Path $sourceDir 'Strings.cs') -Content @'
using UniGetUI.Core.Tools;
namespace Sample;
internal static class Strings
{
public static void Use(int value)
{
_ = CoreTools.Translate("Literal from code");
_ = CoreTools.AutoTranslated("Auto translated literal");
_ = CoreTools.Translate($"Interpolated {value}");
}
}
'@
New-Utf8File -Path (Join-Path $sourceDir 'View.xaml') -Content @'
<Page xmlns:widgets="using:UniGetUI.Interface.Widgets">
<widgets:TranslatedTextBlock Text="WinUI label" />
</Page>
'@
New-Utf8File -Path (Join-Path $sourceDir 'View.axaml') -Content @'
<UserControl xmlns:t="clr-namespace:UniGetUI.Avalonia.MarkupExtensions"
xmlns:settings="clr-namespace:UniGetUI.Avalonia.Views.Controls.Settings">
<StackPanel>
<TextBlock Text="{t:Translate About UniGetUI}" />
<TextBlock Text="{t:Translate Text='A, B, C'}" />
<settings:TranslatedTextBlock Text="Avalonia header" />
</StackPanel>
</UserControl>
'@
$englishPath = Join-Path $languageDir 'lang_en.json'
$initialMap = New-OrderedStringMap
$initialMap['Literal from code'] = 'Literal from code'
$initialMap['Obsolete key'] = 'Obsolete key'
Write-OrderedJsonMap -Path $englishPath -Map $initialMap
$snapshot = Get-TranslationSourceSnapshot -RepositoryRoot $tempRoot
if (@($snapshot.Warnings).Count -ne 1) {
throw "Expected exactly one warning for interpolated CoreTools.Translate, found $(@($snapshot.Warnings).Count)."
}
$threwOnCheck = $false
try {
& $syncScript -RepositoryRoot $tempRoot -EnglishFilePath $englishPath -CheckOnly | Out-Null
}
catch {
if ($_.Exception.Message -notlike '*out of sync*') {
throw
}
$threwOnCheck = $true
}
if (-not $threwOnCheck) {
throw 'Expected check-only mode to detect synchronization differences.'
}
& $syncScript -RepositoryRoot $tempRoot -EnglishFilePath $englishPath | Out-Null
$updatedMap = Read-OrderedJsonMap -Path $englishPath
$expectedKeys = @(
'Literal from code',
'Auto translated literal',
'WinUI label',
'Avalonia header',
'About UniGetUI',
'A, B, C'
)
if ($updatedMap.Count -ne $expectedKeys.Count) {
throw "Expected $($expectedKeys.Count) synchronized keys, found $($updatedMap.Count)."
}
foreach ($expectedKey in $expectedKeys) {
if (-not $updatedMap.Contains($expectedKey)) {
throw "Expected synchronized English file to contain key '$expectedKey'."
}
}
if ($updatedMap.Contains('Obsolete key')) {
throw 'Obsolete key should have been removed during synchronization.'
}
& $syncScript -RepositoryRoot $tempRoot -EnglishFilePath $englishPath -CheckOnly | Out-Null
$boundaryKey = Get-TranslationLegacyBoundaryKey
$boundaryMap = New-OrderedStringMap
$boundaryMap['Literal from code'] = 'Literal from code'
$boundaryMap['Obsolete key'] = 'Obsolete key'
Write-OrderedJsonMap -Path $englishPath -Map $boundaryMap
& $syncScript -RepositoryRoot $tempRoot -EnglishFilePath $englishPath -UseLegacyBoundary | Out-Null
$boundaryUpdatedMap = Read-OrderedJsonMap -Path $englishPath
$boundarySections = Split-TranslationMapAtBoundary -Map $boundaryUpdatedMap
if (-not $boundarySections.HasBoundary) {
throw 'Expected the synchronized English file to include the legacy boundary marker.'
}
if (-not $boundarySections.LegacyMap.Contains('Obsolete key')) {
throw 'Obsolete key should have been preserved below the legacy boundary.'
}
if ($boundarySections.ActiveMap.Contains('Obsolete key')) {
throw 'Obsolete key should not remain in the active key section.'
}
if (-not $boundaryUpdatedMap.Contains($boundaryKey)) {
throw 'Expected the legacy boundary marker key to exist in the synchronized English file.'
}
& $syncScript -RepositoryRoot $tempRoot -EnglishFilePath $englishPath -UseLegacyBoundary -CheckOnly | Out-Null
Write-Output 'Translation source sync smoke test completed successfully.'
}
finally {
Remove-Item -Path $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
}
+322
View File
@@ -0,0 +1,322 @@
[CmdletBinding()]
param(
[string]$LanguagesDirectory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$simplePlaceholderPattern = '^(?<name>[A-Za-z0-9_]+)(?:,[^}:]+)?(?::[^}]+)?$'
$icuControlPattern = '^(?<name>[A-Za-z0-9_]+)\s*,\s*(?<kind>plural|select|selectordinal)\s*,(?<body>[\s\S]*)$'
function Get-RepositoryRoot {
return [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
}
function Resolve-LanguagesDirectory {
if (-not [string]::IsNullOrWhiteSpace($LanguagesDirectory)) {
return [System.IO.Path]::GetFullPath($LanguagesDirectory)
}
return Join-Path (Get-RepositoryRoot) 'src\Languages'
}
function Read-JsonObject {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$content = [System.IO.File]::ReadAllText($Path)
if ([string]::IsNullOrWhiteSpace($content)) {
return [ordered]@{}
}
Assert-NoDuplicateJsonKeys -Content $content -Path $Path
$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 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 Get-BraceBlock {
param(
[Parameter(Mandatory = $true)]
[string]$Text,
[Parameter(Mandatory = $true)]
[int]$StartIndex
)
if ($Text[$StartIndex] -ne '{') {
throw "Expected '{' at index $StartIndex."
}
$depth = 0
for ($index = $StartIndex; $index -lt $Text.Length; $index++) {
if ($Text[$index] -eq '{') {
$depth += 1
continue
}
if ($Text[$index] -ne '}') {
continue
}
$depth -= 1
if ($depth -eq 0) {
return [pscustomobject]@{
Content = $Text.Substring($StartIndex + 1, $index - $StartIndex - 1)
EndIndex = $index
}
}
}
return $null
}
function Add-TokenCount {
param(
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$Counts,
[Parameter(Mandatory = $true)]
[string]$Token,
[int]$Amount = 1
)
if ($Counts.Contains($Token)) {
$Counts[$Token] = [int]$Counts[$Token] + $Amount
}
else {
$Counts[$Token] = $Amount
}
}
function Get-SimplePlaceholderToken {
param(
[Parameter(Mandatory = $true)]
[string]$Content
)
$trimmed = $Content.Trim()
if ($trimmed -notmatch $simplePlaceholderPattern) {
return $null
}
return '{' + $matches.name + '}'
}
function Get-IcuBranchTokenCounts {
param(
[Parameter(Mandatory = $true)]
[string]$Body
)
$maxCounts = [ordered]@{}
$index = 0
while ($index -lt $Body.Length) {
while ($index -lt $Body.Length -and [char]::IsWhiteSpace($Body[$index])) {
$index += 1
}
if ($index -ge $Body.Length) {
break
}
if ($Body[$index] -eq '{') {
$messageBlock = Get-BraceBlock -Text $Body -StartIndex $index
if ($null -eq $messageBlock) {
$index += 1
continue
}
$branchCounts = Get-TokenCounts -Text $messageBlock.Content
foreach ($token in $branchCounts.Keys) {
$branchCount = [int]$branchCounts[$token]
if ($maxCounts.Contains($token)) {
$maxCounts[$token] = [Math]::Max([int]$maxCounts[$token], $branchCount)
}
else {
$maxCounts[$token] = $branchCount
}
}
$index = $messageBlock.EndIndex + 1
continue
}
while ($index -lt $Body.Length -and -not [char]::IsWhiteSpace($Body[$index]) -and $Body[$index] -ne '{') {
$index += 1
}
}
return $maxCounts
}
function Get-TokenCounts {
param(
[AllowEmptyString()]
[string]$Text
)
$counts = [ordered]@{}
if ($null -eq $Text) {
return $counts
}
$index = 0
while ($index -lt $Text.Length) {
if ($Text[$index] -ne '{') {
$index += 1
continue
}
$block = Get-BraceBlock -Text $Text -StartIndex $index
if ($null -eq $block) {
$index += 1
continue
}
$content = $block.Content
if ($content.Trim() -match $icuControlPattern) {
$branchCounts = Get-IcuBranchTokenCounts -Body $matches.body
foreach ($token in $branchCounts.Keys) {
Add-TokenCount -Counts $counts -Token $token -Amount ([int]$branchCounts[$token])
}
}
else {
$token = Get-SimplePlaceholderToken -Content $content
if ($null -ne $token) {
Add-TokenCount -Counts $counts -Token $token
}
}
$index = $block.EndIndex + 1
}
return $counts
}
function Get-TokenMismatches {
param(
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$ExpectedCounts,
[Parameter(Mandatory = $true)]
[System.Collections.IDictionary]$ActualCounts
)
$mismatches = New-Object System.Collections.Generic.List[string]
$allTokens = @($ExpectedCounts.Keys + $ActualCounts.Keys | Sort-Object -Unique)
foreach ($token in $allTokens) {
$expected = if ($ExpectedCounts.Contains($token)) { [int]$ExpectedCounts[$token] } else { 0 }
$actual = if ($ActualCounts.Contains($token)) { [int]$ActualCounts[$token] } else { 0 }
if ($expected -eq $actual) {
continue
}
if ($actual -lt $expected) {
$mismatches.Add("missing $token (expected $expected, found $actual)")
}
else {
$mismatches.Add("unexpected $token (expected $expected, found $actual)")
}
}
return @($mismatches)
}
$issues = 0
$validatedFiles = 0
try {
$resolvedLanguagesDirectory = Resolve-LanguagesDirectory
if (-not (Test-Path -Path $resolvedLanguagesDirectory -PathType Container)) {
throw "Languages directory not found: $resolvedLanguagesDirectory"
}
$languageFiles = Get-ChildItem -Path $resolvedLanguagesDirectory -Filter 'lang_*.json' -File | Sort-Object Name
foreach ($languageFile in $languageFiles) {
try {
$translations = Read-JsonObject -Path $languageFile.FullName
}
catch {
$issues += 1
Write-Output "[$($languageFile.Name)] Failed to parse JSON: $($_.Exception.Message)"
continue
}
$validatedFiles += 1
foreach ($entry in $translations.GetEnumerator()) {
$sourceText = [string]$entry.Key
$translatedText = if ($null -eq $entry.Value) { '' } else { [string]$entry.Value }
if ([string]::IsNullOrWhiteSpace($translatedText)) {
continue
}
$expectedCounts = Get-TokenCounts -Text $sourceText
$actualCounts = Get-TokenCounts -Text $translatedText
$mismatches = @(Get-TokenMismatches -ExpectedCounts $expectedCounts -ActualCounts $actualCounts)
if ($mismatches.Count -eq 0) {
continue
}
$issues += 1
Write-Output "[$($languageFile.Name)] Placeholder mismatch for key: $sourceText"
Write-Output " Translation: $translatedText"
foreach ($mismatch in $mismatches) {
Write-Output " $mismatch"
}
}
}
if ($issues -gt 0) {
Write-Output "Translation verification failed with $issues issue(s)."
exit 1
}
Write-Output "Validated $validatedFiles translation file(s); no placeholder issues found."
exit 0
}
catch {
Write-Error $_.Exception.Message
exit 1
}