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'." } }