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
+1
View File
@@ -0,0 +1 @@
106
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Builds a local UniGetUI Chocolatey package.
.DESCRIPTION
Reproduces the Chocolatey packaging portion of Devolutions/release-notes CI.
By default it sources the installer and checksum from GitHub Releases instead
of OneDrive. It can also build a fresh local x64 installer from the current
checkout and embed that installer into the nupkg for local test-environment
debugging.
.PARAMETER Version
UniGetUI version to package, for example 2026.1.6. If omitted, the latest
GitHub release is used.
.PARAMETER OutputPath
Directory where the downloaded assets, generated package files, and final
.nupkg are written. Default: ./output/chocolatey
.PARAMETER BuildLocalInstaller
Build a fresh local x64 installer from the current checkout and package that
installer into the nupkg instead of using a GitHub release asset.
.PARAMETER InstallerPath
Use an existing local installer file instead of downloading from GitHub.
The installer is embedded into the generated nupkg.
.PARAMETER SkipLocalBuildTests
When BuildLocalInstaller is set, skip running tests during the local build.
.EXAMPLE
./scripts/build-chocolatey-package.ps1
.EXAMPLE
./scripts/build-chocolatey-package.ps1 -Version 2026.1.6
.EXAMPLE
./scripts/build-chocolatey-package.ps1 -BuildLocalInstaller -Version 2026.1.6 -SkipLocalBuildTests
#>
[CmdletBinding()]
param(
[string] $Version,
[string] $OutputPath = (Join-Path $PSScriptRoot ".." "output" "chocolatey"),
[switch] $BuildLocalInstaller,
[string] $InstallerPath,
[switch] $SkipLocalBuildTests
)
$ErrorActionPreference = 'Stop'
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$TemplateDir = Join-Path $RepoRoot "package" "chocolatey" "unigetui"
$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$DownloadDir = Join-Path $OutputPath "downloads"
$StagingRoot = Join-Path $OutputPath "staging"
$StagingDir = Join-Path $StagingRoot "unigetui"
function Invoke-GitHubApi {
param(
[Parameter(Mandatory)]
[string] $Uri
)
$headers = @{
'Accept' = 'application/vnd.github+json'
'User-Agent' = 'UniGetUI-Chocolatey-Local-Pack'
}
if ($env:GITHUB_TOKEN) {
$headers['Authorization'] = "Bearer $($env:GITHUB_TOKEN)"
}
Invoke-RestMethod -Uri $Uri -Headers $headers
}
function Download-File {
param(
[Parameter(Mandatory)]
[string] $Uri,
[Parameter(Mandatory)]
[string] $DestinationPath
)
$headers = @{ 'User-Agent' = 'UniGetUI-Chocolatey-Local-Pack' }
if ($env:GITHUB_TOKEN) {
$headers['Authorization'] = "Bearer $($env:GITHUB_TOKEN)"
}
Invoke-WebRequest -Uri $Uri -Headers $headers -OutFile $DestinationPath
}
function Get-Release {
param(
[string] $RequestedVersion
)
if ([string]::IsNullOrWhiteSpace($RequestedVersion)) {
return Invoke-GitHubApi -Uri 'https://api.github.com/repos/Devolutions/UniGetUI/releases/latest'
}
$tag = if ($RequestedVersion.StartsWith('v')) { $RequestedVersion } else { "v$RequestedVersion" }
$escapedTag = [System.Uri]::EscapeDataString($tag)
return Invoke-GitHubApi -Uri "https://api.github.com/repos/Devolutions/UniGetUI/releases/tags/$escapedTag"
}
function Get-CurrentPackageVersion {
$assemblyInfoPath = Join-Path $RepoRoot 'src' 'SharedAssemblyInfo.cs'
$versionMatch = Select-String -Path $assemblyInfoPath -Pattern 'AssemblyInformationalVersion\("([^"]+)"\)'
if (-not $versionMatch) {
throw "Could not determine the current package version from $assemblyInfoPath"
}
return $versionMatch.Matches[0].Groups[1].Value
}
function Get-ChecksumFromFile {
param(
[Parameter(Mandatory)]
[string] $ChecksumsPath,
[Parameter(Mandatory)]
[string] $AssetName
)
$pattern = "^(?<hash>[A-Fa-f0-9]{64})\s+$([regex]::Escape($AssetName))$"
$line = Select-String -Path $ChecksumsPath -Pattern $pattern | Select-Object -First 1
if (-not $line) {
throw "Could not find a SHA256 for '$AssetName' in $ChecksumsPath"
}
return $line.Matches[0].Groups['hash'].Value.ToUpperInvariant()
}
function Require-Command {
param(
[Parameter(Mandatory)]
[string] $Name
)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required command '$Name' was not found in PATH."
}
}
Require-Command -Name 'choco'
if (-not (Test-Path $TemplateDir)) {
throw "Chocolatey template folder not found: $TemplateDir"
}
if ($BuildLocalInstaller -and $InstallerPath) {
throw 'BuildLocalInstaller and InstallerPath cannot be used together.'
}
$useEmbeddedInstaller = $BuildLocalInstaller -or -not [string]::IsNullOrWhiteSpace($InstallerPath)
$installerAssetName = 'UniGetUI.Installer.x64.exe'
$installerUrl = $null
$release = $null
if ($BuildLocalInstaller) {
$localBuildOutputPath = Join-Path $OutputPath 'local-build'
$buildArgs = @{
Platform = 'x64'
OutputPath = $localBuildOutputPath
}
if (-not [string]::IsNullOrWhiteSpace($Version)) {
$buildArgs['Version'] = $Version
}
if ($SkipLocalBuildTests) {
$buildArgs['SkipTests'] = $true
}
Write-Host 'Building local UniGetUI installer from the current checkout'
& (Join-Path $PSScriptRoot 'build.ps1') @buildArgs
if ($LASTEXITCODE -ne 0) {
throw "Local installer build failed with exit code $LASTEXITCODE"
}
$InstallerPath = Join-Path $localBuildOutputPath $installerAssetName
if (-not (Test-Path $InstallerPath)) {
throw "Local installer was not produced at $InstallerPath"
}
$packageVersion = if ([string]::IsNullOrWhiteSpace($Version)) { Get-CurrentPackageVersion } else { $Version }
Write-Host "Packaging UniGetUI Chocolatey package from local installer $InstallerPath"
}
elseif (-not [string]::IsNullOrWhiteSpace($InstallerPath)) {
$InstallerPath = [System.IO.Path]::GetFullPath($InstallerPath)
if (-not (Test-Path $InstallerPath)) {
throw "InstallerPath was not found: $InstallerPath"
}
$packageVersion = if ([string]::IsNullOrWhiteSpace($Version)) { Get-CurrentPackageVersion } else { $Version }
Write-Host "Packaging UniGetUI Chocolatey package from local installer $InstallerPath"
}
else {
$release = Get-Release -RequestedVersion $Version
$packageVersion = $release.tag_name.TrimStart('v')
$installerAsset = $release.assets | Where-Object { $_.name -eq $installerAssetName } | Select-Object -First 1
if (-not $installerAsset) {
throw "Could not find asset '$installerAssetName' in release $($release.tag_name)"
}
$checksumsAsset = $release.assets | Where-Object { $_.name -eq 'checksums.txt' } | Select-Object -First 1
if (-not $checksumsAsset) {
throw "Could not find asset 'checksums.txt' in release $($release.tag_name)"
}
Write-Host "Packaging UniGetUI Chocolatey package for release $($release.tag_name)"
Write-Host "Installer asset: $($installerAsset.browser_download_url)"
$installerPath = Join-Path $DownloadDir $installerAsset.name
$checksumsPath = Join-Path $DownloadDir $checksumsAsset.name
Download-File -Uri $installerAsset.browser_download_url -DestinationPath $installerPath
Download-File -Uri $checksumsAsset.browser_download_url -DestinationPath $checksumsPath
$sha256 = Get-ChecksumFromFile -ChecksumsPath $checksumsPath -AssetName $installerAsset.name
$installerUrl = $installerAsset.browser_download_url
}
New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
New-Item -Path $DownloadDir -ItemType Directory -Force | Out-Null
New-Item -Path $StagingRoot -ItemType Directory -Force | Out-Null
if (Test-Path $StagingRoot) {
Remove-Item $StagingRoot -Recurse -Force
}
New-Item -Path $StagingRoot -ItemType Directory -Force | Out-Null
Copy-Item -Path $TemplateDir -Destination $StagingDir -Recurse
if ($useEmbeddedInstaller) {
$embeddedInstallerName = Split-Path $InstallerPath -Leaf
$embeddedInstallerPath = Join-Path $StagingDir 'tools' $embeddedInstallerName
Copy-Item -Path $InstallerPath -Destination $embeddedInstallerPath -Force
$sha256 = (Get-FileHash -Path $InstallerPath -Algorithm SHA256).Hash.ToUpperInvariant()
}
$nuspecTemplatePath = Join-Path $StagingDir 'unigetui.template.nuspec'
$installTemplatePath = Join-Path $StagingDir 'tools' 'chocolateyInstall.template.ps1'
$nuspecPath = Join-Path $StagingDir 'unigetui.nuspec'
$installScriptPath = Join-Path $StagingDir 'tools' 'chocolateyInstall.ps1'
$nuspecContent = (Get-Content $nuspecTemplatePath -Raw).Replace('$VAR1$', $packageVersion)
if ($useEmbeddedInstaller) {
$nuspecContent = $nuspecContent.Replace(
' <file src="tools\chocolateyUninstall.ps1" target="tools" />',
(' <file src="tools\chocolateyUninstall.ps1" target="tools" />' + "`r`n" + (' <file src="tools\{0}" target="tools" />' -f $embeddedInstallerName))
)
}
$installContent = Get-Content $installTemplatePath -Raw
if ($useEmbeddedInstaller) {
$installContent = $installContent.Replace(
'$Url = ''https://cdn.devolutions.net/download/Devolutions.UniGetUI.win-x64.$VAR1$.exe''',
('$InstallerPath = Join-Path $PSScriptRoot ''{0}''' -f $embeddedInstallerName)
)
$installContent = $installContent.Replace(' url = $Url', ' file = $InstallerPath')
$installContent = $installContent -replace "(?m)^\s*checksum\s*=.*\r?\n", ''
$installContent = $installContent -replace "(?m)^\s*checksumType\s*=.*\r?\n", ''
}
else {
$installContent = $installContent.Replace('$VAR2$', $sha256)
$installContent = $installContent.Replace(
"'https://cdn.devolutions.net/download/Devolutions.UniGetUI.win-x64.`$VAR1$.exe'",
"'$installerUrl'"
)
}
$installContent = $installContent.Replace('$VAR1$', $packageVersion)
Set-Content -Path $nuspecPath -Value $nuspecContent -Encoding utf8NoBOM
Set-Content -Path $installScriptPath -Value $installContent -Encoding utf8NoBOM
Push-Location $StagingDir
try {
& choco pack 'unigetui.nuspec' --outputdirectory $OutputPath
if ($LASTEXITCODE -ne 0) {
throw "choco pack failed with exit code $LASTEXITCODE"
}
}
finally {
Pop-Location
}
$packagePath = Join-Path $OutputPath "unigetui.$packageVersion.nupkg"
if (-not (Test-Path $packagePath)) {
$packagePath = Get-ChildItem -Path $OutputPath -Filter 'unigetui.*.nupkg' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName
}
Write-Host "SHA256 (installer): $sha256"
Write-Host "Package written to: $packagePath"
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Builds UniGetUI, produces the published output, and packages artifacts.
.PARAMETER Configuration
Build configuration (Debug or Release). Default: Release.
.PARAMETER Platform
Target platform. Default: x64.
.PARAMETER OutputPath
Directory for final packaged artifacts (zip, installer). Default: ./output
.PARAMETER SkipTests
Skip running dotnet test before build.
.PARAMETER SkipInstaller
Skip building the Inno Setup installer.
.PARAMETER Version
Version string to stamp into the build (e.g. "3.3.7"). If not provided,
the current version from SharedAssemblyInfo.cs is used.
.PARAMETER MaxInstallerCompression
Use the strongest Inno Setup compression settings for the installer.
#>
[CmdletBinding()]
param(
[string] $Configuration = "Release",
[string] $Platform = "x64",
[string] $OutputPath = (Join-Path $PSScriptRoot ".." "output"),
[switch] $SkipTests,
[switch] $SkipInstaller,
[switch] $MaxInstallerCompression,
[string] $Version
)
$ErrorActionPreference = 'Stop'
$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$SrcDir = Join-Path $RepoRoot "src"
$WindowsSolution = Join-Path $SrcDir "UniGetUI.Windows.slnx"
$PublishProject = Join-Path $SrcDir "UniGetUI.Avalonia" "UniGetUI.Avalonia.csproj"
$BinDir = Join-Path $RepoRoot "unigetui_bin"
$BuildPropsPath = Join-Path $SrcDir "Directory.Build.props"
[xml] $BuildProps = Get-Content $BuildPropsPath
$PortableTargetFramework = @($BuildProps.Project.PropertyGroup | Where-Object { $_.PortableTargetFramework } | Select-Object -First 1).PortableTargetFramework
$WindowsTargetPlatformVersion = @($BuildProps.Project.PropertyGroup | Where-Object { $_.WindowsTargetPlatformVersion } | Select-Object -First 1).WindowsTargetPlatformVersion
if ([string]::IsNullOrWhiteSpace($PortableTargetFramework) -or [string]::IsNullOrWhiteSpace($WindowsTargetPlatformVersion)) {
throw "Could not resolve the target framework from $BuildPropsPath"
}
$TargetFramework = "$PortableTargetFramework-windows$WindowsTargetPlatformVersion"
$PublishDir = Join-Path $SrcDir "UniGetUI.Avalonia" "bin" $Platform $Configuration $TargetFramework "win-$Platform" "publish"
# --- Version stamping ---
if ($Version) {
Write-Host "Stamping version: $Version"
& (Join-Path $PSScriptRoot "set-version.ps1") -Version $Version
}
# --- Read version from SharedAssemblyInfo.cs ---
$AssemblyInfoPath = Join-Path $SrcDir "SharedAssemblyInfo.cs"
$VersionMatch = Select-String -Path $AssemblyInfoPath -Pattern 'AssemblyInformationalVersion\("([^"]+)"\)'
$PackageVersion = if ($VersionMatch) { $VersionMatch.Matches[0].Groups[1].Value } else { "0.0.0" }
Write-Host "Building UniGetUI version: $PackageVersion"
# --- Test ---
if (-not $SkipTests) {
Write-Host "`n=== Running tests ===" -ForegroundColor Cyan
dotnet test $WindowsSolution --verbosity q --nologo --ignore-failed-sources /p:Platform=$Platform
if ($LASTEXITCODE -ne 0) {
throw "Tests failed with exit code $LASTEXITCODE"
}
}
# --- Build / Publish ---
Write-Host "`n=== Publishing $Configuration|$Platform ===" -ForegroundColor Cyan
dotnet clean $WindowsSolution -v m --nologo /p:Platform=$Platform
dotnet publish $PublishProject /noLogo /p:Configuration=$Configuration /p:Platform=$Platform -p:RuntimeIdentifier=win-$Platform --ignore-failed-sources -v m
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish Avalonia failed with exit code $LASTEXITCODE"
}
# --- Stage binaries ---
if (Test-Path $BinDir) { Remove-Item $BinDir -Recurse -Force }
New-Item $BinDir -ItemType Directory | Out-Null
# Move published output into unigetui_bin
Get-ChildItem $PublishDir | Move-Item -Destination $BinDir -Force
$WindowsAppHostPath = Join-Path $BinDir "UniGetUI.exe"
if (-not (Test-Path $WindowsAppHostPath)) {
throw "Windows app host was not produced at $WindowsAppHostPath"
}
# Keep smaller symbols for useful local crash source information, and prune oversized ones.
$MaxShippedPdbSizeBytes = 1MB
$PdbsToRemove = Get-ChildItem $BinDir -Filter "*.pdb" -File | Where-Object {
$_.Length -gt $MaxShippedPdbSizeBytes
}
if ($PdbsToRemove.Count -gt 0) {
$RemovedPdbBytes = ($PdbsToRemove | Measure-Object -Property Length -Sum).Sum
$PdbsToRemove | Remove-Item -Force
Write-Host ("Removed {0} oversized PDBs above {1:N2} MiB ({2:N2} MiB total)." -f $PdbsToRemove.Count, ($MaxShippedPdbSizeBytes / 1MB), ($RemovedPdbBytes / 1MB))
}
# --- Package output ---
if (Test-Path $OutputPath) { Remove-Item $OutputPath -Recurse -Force }
New-Item $OutputPath -ItemType Directory | Out-Null
$ZipPath = Join-Path $OutputPath "UniGetUI.$Platform.zip"
Write-Host "`n=== Refreshing integrity tree before zip packaging ===" -ForegroundColor Cyan
& (Join-Path $PSScriptRoot "refresh-integrity-tree.ps1") -Path $BinDir -FailOnUnexpectedFiles
Write-Host "`n=== Creating zip: $ZipPath ===" -ForegroundColor Cyan
Compress-Archive -Path (Join-Path $BinDir "*") -DestinationPath $ZipPath -CompressionLevel Optimal
# --- Installer (Inno Setup) ---
if (-not $SkipInstaller) {
$IsccPath = $null
# Search common install locations
foreach ($candidate in @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe"
)) {
if (Test-Path $candidate) { $IsccPath = $candidate; break }
}
if ($IsccPath) {
Write-Host "`n=== Building installer ===" -ForegroundColor Cyan
$InstallerBaseName = "UniGetUI.Installer.$Platform"
$IssPath = Join-Path $RepoRoot "UniGetUI.iss"
$IssContent = Get-Content $IssPath -Raw
Write-Host "`n=== Refreshing integrity tree before installer packaging ===" -ForegroundColor Cyan
& (Join-Path $PSScriptRoot "refresh-integrity-tree.ps1") -Path $BinDir -FailOnUnexpectedFiles
try {
$IssContentNoSign = $IssContent -Replace '(?m)^SignTool=.*$', '; SignTool=azsign (disabled for local build)'
$IssContentNoSign = $IssContentNoSign -Replace '(?m)^SignedUninstaller=yes', 'SignedUninstaller=no'
Set-Content $IssPath $IssContentNoSign -NoNewline
$IsccArgs = @($IssPath, "/F$InstallerBaseName", "/O$OutputPath")
if ($MaxInstallerCompression) {
Write-Host "Using lzma/ultra64 installer compression."
$IsccArgs = @('/DInstallerCompression=lzma/ultra64') + $IsccArgs
}
& $IsccPath @IsccArgs
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup failed with exit code $LASTEXITCODE"
}
}
finally {
Set-Content $IssPath $IssContent -NoNewline
}
} else {
Write-Warning "Inno Setup 6 (ISCC.exe) not found — skipping installer build."
}
}
# --- Checksums ---
Write-Host "`n=== Checksums ===" -ForegroundColor Cyan
$ChecksumFile = Join-Path $OutputPath "checksums.$Platform.txt"
Get-ChildItem $OutputPath -File | Where-Object { $_.Name -notlike "checksums.*.txt" } | ForEach-Object {
$hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash
"$hash $($_.Name)" | Tee-Object -FilePath $ChecksumFile -Append
}
# --- Cleanup ---
if (Test-Path $BinDir) { Remove-Item $BinDir -Recurse -Force }
Write-Host "`n=== Build complete ===" -ForegroundColor Green
Write-Host "Artifacts in: $OutputPath"
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Exports the icon database to a browsable Markdown gallery.
.DESCRIPTION
Reads WebBasedData/screenshot-database-v2.json and writes a Markdown file
that lists every package with embedded remote icon and screenshot images,
making it easy to review the database in a Markdown preview without
downloading each asset manually.
.EXAMPLE
./scripts/export-icon-database-gallery.ps1
Writes WebBasedData/screenshot-database-gallery.md.
.EXAMPLE
./scripts/export-icon-database-gallery.ps1 -MaxPackages 100 -OutputPath temp/icon-gallery.md
Writes a gallery for the first 100 package entries to temp/icon-gallery.md.
.EXAMPLE
./scripts/export-icon-database-gallery.ps1 -IncludePackageName 'devolutions*'
Writes a gallery that only includes package names matching the provided
wildcard pattern.
.EXAMPLE
./scripts/export-icon-database-gallery.ps1 -IncludePackageName 'remote','rdm' -ExcludePackageName '*agent*'
Writes a gallery for packages whose names contain remote or rdm, excluding
names that contain agent.
#>
[CmdletBinding()]
param(
[string] $JsonPath = 'WebBasedData/screenshot-database-v2.json',
[string] $OutputPath = 'WebBasedData/screenshot-database-gallery.md',
[string[]] $IncludePackageName = @(),
[string[]] $ExcludePackageName = @(),
[int] $MaxPackages,
[int] $IconWidth = 96,
[int] $ScreenshotWidth = 360
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
function Resolve-RepoPath {
param(
[Parameter(Mandatory)]
[string] $Path
)
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path $RepoRoot $Path))
}
function Write-Utf8File {
param(
[Parameter(Mandatory)]
[string] $Path,
[Parameter(Mandatory)]
[AllowEmptyString()]
[string] $Content
)
$directory = Split-Path -Parent $Path
if (-not [string]::IsNullOrWhiteSpace($directory)) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
}
$encoding = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
}
function Read-IconDatabase {
param(
[Parameter(Mandatory)]
[string] $Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Icon database file not found: $Path"
}
$database = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable
if (-not ($database -is [System.Collections.IDictionary])) {
throw 'Icon database root must be a JSON object.'
}
if (-not $database.Contains('icons_and_screenshots')) {
throw 'Icon database is missing icons_and_screenshots.'
}
if (-not ($database['icons_and_screenshots'] -is [System.Collections.IDictionary])) {
throw 'icons_and_screenshots must be a JSON object.'
}
return $database
}
function Normalize-Entry {
param(
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Entry
)
if (-not $Entry.Contains('icon') -or $null -eq $Entry['icon']) {
$Entry['icon'] = ''
}
if (-not $Entry.Contains('images') -or $null -eq $Entry['images']) {
$Entry['images'] = @()
}
$images = @()
foreach ($image in @($Entry['images'])) {
if ($null -eq $image) {
continue
}
$value = [string] $image
if ([string]::IsNullOrWhiteSpace($value)) {
continue
}
$images += $value
}
$Entry['icon'] = [string] $Entry['icon']
$Entry['images'] = $images
}
function Get-MarkdownSafeText {
param(
[Parameter(Mandatory)]
[string] $Value
)
return $Value.Replace('&', '&amp;').Replace('<', '&lt;').Replace('>', '&gt;')
}
function New-RemoteImageMarkup {
param(
[Parameter(Mandatory)]
[string] $Url,
[Parameter(Mandatory)]
[string] $Alt,
[Parameter(Mandatory)]
[int] $Width
)
$safeAlt = Get-MarkdownSafeText -Value $Alt
$safeUrl = Get-MarkdownSafeText -Value $Url
return ('<a href="{0}"><img src="{0}" alt="{1}" width="{2}" /></a>' -f $safeUrl, $safeAlt, $Width)
}
function ConvertTo-WildcardPattern {
param(
[Parameter(Mandatory)]
[string] $Value
)
$trimmedValue = $Value.Trim()
if ([string]::IsNullOrWhiteSpace($trimmedValue)) {
return $null
}
if ($trimmedValue.IndexOfAny(@('*', '?')) -ge 0) {
return $trimmedValue
}
return ('*{0}*' -f $trimmedValue)
}
function Test-PackageNameMatch {
param(
[Parameter(Mandatory)]
[string] $PackageId,
[Parameter(Mandatory)]
[string[]] $Patterns
)
foreach ($pattern in $Patterns) {
if ($PackageId -like $pattern) {
return $true
}
}
return $false
}
$resolvedJsonPath = Resolve-RepoPath -Path $JsonPath
$resolvedOutputPath = Resolve-RepoPath -Path $OutputPath
$database = Read-IconDatabase -Path $resolvedJsonPath
$entries = $database['icons_and_screenshots']
$includePatterns = @(
$IncludePackageName |
ForEach-Object { ConvertTo-WildcardPattern -Value $_ } |
Where-Object { $null -ne $_ }
)
$excludePatterns = @(
$ExcludePackageName |
ForEach-Object { ConvertTo-WildcardPattern -Value $_ } |
Where-Object { $null -ne $_ }
)
$builder = [System.Text.StringBuilder]::new()
$generatedUtc = [DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss 'UTC'")
$exportedPackages = 0
[void] $builder.AppendLine('# Icon Database Gallery')
[void] $builder.AppendLine()
[void] $builder.AppendLine("Generated from $JsonPath on $generatedUtc.")
[void] $builder.AppendLine()
if ($includePatterns.Count -gt 0) {
[void] $builder.AppendLine(('Included package filters: {0}' -f (($IncludePackageName | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ', ')))
}
if ($excludePatterns.Count -gt 0) {
[void] $builder.AppendLine(('Excluded package filters: {0}' -f (($ExcludePackageName | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ', ')))
}
[void] $builder.AppendLine()
[void] $builder.AppendLine('This file is intended for Markdown preview. Click any image to open the original URL.')
[void] $builder.AppendLine()
$packageCount = 0
foreach ($packageId in $entries.Keys) {
if ($MaxPackages -gt 0 -and $packageCount -ge $MaxPackages) {
break
}
if ($packageId -eq '__test_entry_DO_NOT_EDIT_PLEASE') {
continue
}
if ($includePatterns.Count -gt 0 -and -not (Test-PackageNameMatch -PackageId $packageId -Patterns $includePatterns)) {
continue
}
if ($excludePatterns.Count -gt 0 -and (Test-PackageNameMatch -PackageId $packageId -Patterns $excludePatterns)) {
continue
}
$entry = $entries[$packageId]
Normalize-Entry -Entry $entry
$iconUrl = [string] $entry['icon']
$images = @($entry['images'])
[void] $builder.AppendLine(('## {0}' -f $packageId))
[void] $builder.AppendLine()
if ([string]::IsNullOrWhiteSpace($iconUrl)) {
[void] $builder.AppendLine('Icon: none')
}
else {
[void] $builder.AppendLine('Icon:')
[void] $builder.AppendLine()
[void] $builder.AppendLine((New-RemoteImageMarkup -Url $iconUrl -Alt "$packageId icon" -Width $IconWidth))
[void] $builder.AppendLine()
[void] $builder.AppendLine($iconUrl)
}
[void] $builder.AppendLine()
if ($images.Count -eq 0) {
[void] $builder.AppendLine('Screenshots: none')
}
else {
[void] $builder.AppendLine(('Screenshots: {0}' -f $images.Count))
[void] $builder.AppendLine()
$screenshotIndex = 1
foreach ($imageUrl in $images) {
[void] $builder.AppendLine(('Screenshot {0}:' -f $screenshotIndex))
[void] $builder.AppendLine()
[void] $builder.AppendLine((New-RemoteImageMarkup -Url $imageUrl -Alt ('{0} screenshot {1}' -f $packageId, $screenshotIndex) -Width $ScreenshotWidth))
[void] $builder.AppendLine()
[void] $builder.AppendLine($imageUrl)
[void] $builder.AppendLine()
$screenshotIndex++
}
}
[void] $builder.AppendLine('---')
[void] $builder.AppendLine()
$packageCount++
$exportedPackages++
}
[void] $builder.Insert(0, ("Packages exported: {0}`r`n`r`n" -f $exportedPackages))
Write-Utf8File -Path $resolvedOutputPath -Content $builder.ToString()
Write-Host "Wrote icon database gallery to $resolvedOutputPath"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Generates an IntegrityTree.json file containing SHA256 hashes of all files
in the specified directory. Used at build time; verified at runtime by
UniGetUI.Core.Tools.IntegrityTester.
.PARAMETER Path
The directory to scan (typically the publish/output directory).
.PARAMETER MinOutput
Suppress per-file progress output.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string] $Path,
[switch] $MinOutput
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $Path -PathType Container)) {
throw "The directory '$Path' does not exist."
}
$Path = (Resolve-Path $Path).Path
$OutputFileName = 'IntegrityTree.json'
$ScriptName = [System.IO.Path]::GetFileName($PSCommandPath)
$integrityData = [ordered]@{}
Get-ChildItem $Path -Recurse -File | ForEach-Object {
$relativePath = $_.FullName.Substring($Path.Length).TrimStart('\', '/') -replace '\\', '/'
# Skip the output file itself
if ($relativePath -eq $OutputFileName) { return }
if (-not $MinOutput) {
Write-Host " - Computing SHA256 of $relativePath..."
}
$hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
$integrityData[$relativePath] = $hash
}
# Sort keys for deterministic output
$sorted = [ordered]@{}
foreach ($key in ($integrityData.Keys | Sort-Object)) {
$sorted[$key] = $integrityData[$key]
}
$json = $sorted | ConvertTo-Json -Depth 1
Set-Content (Join-Path $Path $OutputFileName) $json -Encoding utf8NoBOM -NoNewline
Write-Host "Integrity tree was generated and saved to $Path/$OutputFileName"
+58
View File
@@ -0,0 +1,58 @@
[CmdletBinding()]
param(
[string]$Repository = 'Devolutions/UniGetUI',
[string]$OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-RepositoryRoot {
return [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
}
function Resolve-OutputPath {
if (-not [string]::IsNullOrWhiteSpace($OutputPath)) {
return [System.IO.Path]::GetFullPath($OutputPath)
}
return Join-Path (Get-RepositoryRoot) 'src\UniGetUI.Core.Data\Assets\Data\Contributors.list'
}
function Write-Utf8Lines {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string[]]$Lines
)
$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]::WriteAllLines($Path, $Lines, $encoding)
}
$resolvedOutputPath = Resolve-OutputPath
$contributorsUrl = "https://api.github.com/repos/$Repository/contributors?anon=1&per_page=100"
try {
Write-Output 'Getting contributors...'
$response = Invoke-RestMethod -Uri $contributorsUrl -Headers @{ 'User-Agent' = 'UniGetUI-Scripts' }
$contributors = @(
$response |
Where-Object { $_.type -eq 'User' -and -not [string]::IsNullOrWhiteSpace($_.login) } |
ForEach-Object { [string]$_.login }
)
Write-Utf8Lines -Path $resolvedOutputPath -Lines $contributors
Write-Output "Wrote $($contributors.Count) contributor login(s) to: $resolvedOutputPath"
}
catch {
Write-Error "Failed to fetch contributors: $($_.Exception.Message)"
exit 1
}
+8
View File
@@ -0,0 +1,8 @@
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
git -C $repoRoot config core.hooksPath .githooks
Write-Host 'Configured git hooks path to .githooks' -ForegroundColor Green
Write-Host 'The pre-commit hook will run dotnet format whitespace on staged files under src.' -ForegroundColor Green
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Full-bleed square variant of the Devolutions UniGetUI icon for macOS Icon Composer.
The bands extend edge-to-edge (no own rounded corners) so the system applies the
squircle/Liquid-Glass shape. The grey download arrow is centred. -->
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="72" height="72" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 72 72">
<defs>
<radialGradient id="rg0" cx="3.037" cy="57.459" fx="3.037" fy="57.459" r="121.444" gradientTransform="translate(73) rotate(-180) scale(1 -1)" gradientUnits="userSpaceOnUse"><stop offset=".008" stop-color="#90ffff"/><stop offset=".079" stop-color="#81f8fa"/><stop offset=".213" stop-color="#5de5ed"/><stop offset=".339" stop-color="#35d2e0"/></radialGradient>
<radialGradient id="rg1" cx="15.197" cy="42.817" fx="-20.033" fy="36.517" r="122.054" gradientTransform="translate(73) rotate(-180) scale(1 -1)" gradientUnits="userSpaceOnUse"><stop offset=".032" stop-color="#00f3f4"/><stop offset=".447" stop-color="#048ea7"/></radialGradient>
<radialGradient id="rg2" cx="11.522" cy="28.78" fx="11.522" fy="28.78" r="74.953" gradientTransform="translate(73) rotate(-180) scale(1 -1)" gradientUnits="userSpaceOnUse"><stop offset=".182" stop-color="#348da2"/><stop offset=".833" stop-color="#126176"/></radialGradient>
<linearGradient id="lg" x1="-23.954" y1="3.056" x2="69.823" y2="19.591" gradientTransform="translate(73) rotate(-180) scale(1 -1)" gradientUnits="userSpaceOnUse"><stop offset=".18" stop-color="#25839a"/><stop offset=".555" stop-color="#265564"/></linearGradient>
<radialGradient id="rg3" cx="59.232" cy="65.22" fx="59.232" fy="65.22" r="325.339" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d0d0d0"/><stop offset=".637" stop-color="#c1c1c1"/><stop offset=".922" stop-color="#bababa"/></radialGradient>
</defs>
<rect x="0" y="0" width="72" height="18.5" fill="url(#lg)"/>
<rect x="0" y="18" width="72" height="18.5" fill="url(#rg2)"/>
<rect x="0" y="36" width="72" height="18.5" fill="url(#rg1)"/>
<rect x="0" y="54" width="72" height="18" fill="url(#rg0)"/>
<path fill="url(#rg3)" d="M54.25,34.99l-4.385-4.385c-.807-.807-2.116-.807-2.923,0l-5.941,5.941V0h-10v36.547l-5.941-5.941c-.807-.807-2.116-.807-2.923,0l-4.385,4.385c-.807.807-.807,2.116,0,2.923l14.556,14.557c2.04,2.04,5.347,2.04,7.386,0l14.556-14.557c.807-.807.807-2.116,0-2.923Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+52
View File
@@ -0,0 +1,52 @@
{
"fill" : {
"linear-gradient" : [
"display-p3:0.10196,0.30196,0.36863,1.00000",
"display-p3:0.20784,0.82353,0.87843,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 1
}
}
},
"groups" : [
{
"layers" : [
{
"blend-mode" : "normal",
"glass" : false,
"hidden" : false,
"image-name" : "devolutions-unigetui-icon-fullbleed.svg",
"name" : "devolutions-unigetui-icon",
"position" : {
"scale" : 14.222222,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : false,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>io.github.marticliment.unigetui</string>
<key>CFBundleName</key>
<string>UniGetUI</string>
<key>CFBundleDisplayName</key>
<string>UniGetUI</string>
<key>CFBundleExecutable</key>
<string>UniGetUI</string>
<!-- CFBundleIconName (Assets.car, compiled from AppIcon.icon) drives the macOS 26 appearance
styling; CFBundleIconFile (UniGetUI.icns) is the static fallback for macOS < 26. -->
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIconFile</key>
<string>UniGetUI</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>@VERSION@</string>
<key>CFBundleShortVersionString</key>
<string>@VERSION@</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
Binary file not shown.
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Merges one publish directory into another, allowing only identical file collisions.
.PARAMETER Source
The publish directory to copy from.
.PARAMETER Destination
The publish directory to copy into.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Source,
[Parameter(Mandatory)]
[string] $Destination
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $Source -PathType Container)) {
throw "Source directory '$Source' does not exist."
}
if (-not (Test-Path $Destination -PathType Container)) {
throw "Destination directory '$Destination' does not exist."
}
$Source = (Resolve-Path $Source).Path
$Destination = (Resolve-Path $Destination).Path
$sourceWinsConflicts = @{
'Microsoft.Extensions.DependencyInjection.Abstractions.dll' = $true
'Microsoft.VisualBasic.dll' = $true
'Microsoft.Win32.SystemEvents.dll' = $true
'System.Diagnostics.EventLog.dll' = $true
'System.Diagnostics.EventLog.Messages.dll' = $true
'System.Drawing.Common.dll' = $true
'System.Drawing.dll' = $true
'System.Private.Windows.Core.dll' = $true
'System.Security.Cryptography.Pkcs.dll' = $true
'System.Security.Cryptography.Xml.dll' = $true
'WindowsBase.dll' = $true
}
$destinationWinsConflicts = @{
'Microsoft.Windows.SDK.NET.dll' = $true
'WinRT.Runtime.dll' = $true
}
Get-ChildItem $Source -Recurse -File | ForEach-Object {
$relativePath = $_.FullName.Substring($Source.Length).TrimStart('\', '/')
$destinationPath = Join-Path $Destination $relativePath
$destinationDirectory = Split-Path $destinationPath -Parent
if (Test-Path $destinationPath -PathType Leaf) {
$sourceHash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash
$destinationHash = (Get-FileHash $destinationPath -Algorithm SHA256).Hash
if ($sourceHash -ne $destinationHash) {
$fileName = [System.IO.Path]::GetFileName($relativePath)
if ($sourceWinsConflicts.ContainsKey($fileName)) {
Copy-Item $_.FullName -Destination $destinationPath -Force
return
}
if ($destinationWinsConflicts.ContainsKey($fileName)) {
return
}
throw "Publish merge conflict for '$relativePath': source and destination files differ."
}
return
}
if (-not (Test-Path $destinationDirectory -PathType Container)) {
New-Item $destinationDirectory -ItemType Directory -Force | Out-Null
}
Copy-Item $_.FullName -Destination $destinationPath -Force
}
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Creates a .deb or .rpm package from a pre-built binary directory.
.DESCRIPTION
Builds a native Linux package without requiring Ruby/fpm.
.deb is assembled with tar and ar (binutils).
.rpm is built with rpmbuild (rpm-build).
.PARAMETER PackageType
Package format to produce: 'deb' or 'rpm'.
.PARAMETER SourceDir
Directory containing the pre-built binaries to package.
.PARAMETER OutputPath
Full path to the output package file (e.g. output/unigetui.deb).
.PARAMETER Version
Package version string, already formatted for the target format:
deb: "2026.1.0" or "2026.1.0~beta1"
rpm: "2026.1.0"
.PARAMETER Architecture
Target CPU architecture in the format expected by the package type:
deb: amd64 | arm64
rpm: x86_64 | aarch64
.PARAMETER Iteration
RPM Release/iteration field (default: 1). Ignored for .deb.
.PARAMETER PackageName
Package name (default: unigetui).
.PARAMETER InstallPrefix
Absolute install directory on the target system (default: /opt/unigetui).
.PARAMETER Description
One-line package description.
.PARAMETER Maintainer
Maintainer field value, e.g. "Name <email>".
.PARAMETER Url
Homepage / upstream URL.
.PARAMETER AppExecutableName
Executable filename inside InstallPrefix.
.PARAMETER LauncherName
Public command name exposed in PATH.
.PARAMETER IconSourcePath
Source path for the installed desktop icon.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('deb', 'rpm')]
[string] $PackageType,
[Parameter(Mandatory)]
[string] $SourceDir,
[Parameter(Mandatory)]
[string] $OutputPath,
[Parameter(Mandatory)]
[string] $Version,
[Parameter(Mandatory)]
[string] $Architecture,
[string] $Iteration = '1',
[string] $PackageName = 'unigetui',
[string] $InstallPrefix = '/opt/unigetui',
[string] $Description = 'UniGetUI - GUI for package managers',
[string] $Maintainer = 'Devolutions Inc. <support@devolutions.net>',
[string] $Url = 'https://github.com/Devolutions/UniGetUI',
[string] $AppExecutableName = 'UniGetUI',
[string] $LauncherName = 'unigetui',
[string] $IconSourcePath = (Join-Path $PSScriptRoot '..' 'src' 'SharedAssets' 'Assets' 'Images' 'icon.png')
)
$ErrorActionPreference = 'Stop'
$SourceDir = (Resolve-Path $SourceDir).Path
$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$IconSourcePath = (Resolve-Path $IconSourcePath).Path
$OutDir = Split-Path $OutputPath
if ($OutDir) { New-Item -ItemType Directory -Path $OutDir -Force | Out-Null }
$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "pkg-$(New-Guid)"
New-Item -ItemType Directory -Path $TmpDir | Out-Null
$IconInstallDir = '/usr/share/icons/hicolor/512x512/apps'
$IconTargetName = "$LauncherName.png"
$IconInstallPath = "$IconInstallDir/$IconTargetName"
$DesktopFilePath = "/usr/share/applications/$LauncherName.desktop"
$LauncherPath = "/usr/bin/$LauncherName"
function New-LinuxIntegrationAssets {
param(
[Parameter(Mandatory)]
[string] $StageRoot
)
$payloadDir = Join-Path $StageRoot ($InstallPrefix.TrimStart('/'))
New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null
& /bin/cp -a "$SourceDir/." $payloadDir
if ($LASTEXITCODE -ne 0) { throw "cp (payload staging) exited $LASTEXITCODE" }
$appExecutableFullPath = Join-Path $payloadDir $AppExecutableName
if (-not (Test-Path $appExecutableFullPath -PathType Leaf)) {
throw "App executable '$AppExecutableName' was not found in package payload '$payloadDir'"
}
$launcherFullPath = Join-Path $StageRoot $LauncherPath.TrimStart('/')
New-Item -ItemType Directory -Path (Split-Path $launcherFullPath -Parent) -Force | Out-Null
$launcherCommand = 'exec {0}/{1} "$@"' -f $InstallPrefix, $AppExecutableName
$launcherScript = @(
'#!/bin/sh',
$launcherCommand,
''
) -join "`n"
[System.IO.File]::WriteAllText($launcherFullPath, $launcherScript)
& chmod 755 $launcherFullPath
if ($LASTEXITCODE -ne 0) { throw "chmod (launcher) exited $LASTEXITCODE" }
$desktopEntryFullPath = Join-Path $StageRoot $DesktopFilePath.TrimStart('/')
New-Item -ItemType Directory -Path (Split-Path $desktopEntryFullPath -Parent) -Force | Out-Null
$desktopEntry = @(
'[Desktop Entry]',
'Version=1.0',
'Type=Application',
'Name=UniGetUI',
"Comment=$Description",
"Exec=$LauncherPath",
"Icon=$LauncherName",
'Terminal=false',
'Categories=System;Utility;',
'StartupNotify=true',
''
) -join "`n"
[System.IO.File]::WriteAllText($desktopEntryFullPath, $desktopEntry)
& chmod 644 $desktopEntryFullPath
if ($LASTEXITCODE -ne 0) { throw "chmod (desktop entry) exited $LASTEXITCODE" }
$iconFullPath = Join-Path $StageRoot $IconInstallPath.TrimStart('/')
New-Item -ItemType Directory -Path (Split-Path $iconFullPath -Parent) -Force | Out-Null
Copy-Item -Path $IconSourcePath -Destination $iconFullPath -Force
& chmod 644 $iconFullPath
if ($LASTEXITCODE -ne 0) { throw "chmod (icon) exited $LASTEXITCODE" }
}
try {
# ------------------------------------------------------------------
# .deb
# Structure: ar archive containing debian-binary, control.tar.gz, data.tar.gz
# Tools required: tar, ar (binutils pre-installed on Ubuntu runners)
# ------------------------------------------------------------------
if ($PackageType -eq 'deb') {
# 1. debian-binary (must be the first ar member, content is "2.0\n")
$DebianBinaryPath = Join-Path $TmpDir 'debian-binary'
[System.IO.File]::WriteAllText($DebianBinaryPath, "2.0`n")
# 2. data.tar.gz payload and Linux integration assets staged under target paths
$DataStage = Join-Path $TmpDir 'data'
New-LinuxIntegrationAssets -StageRoot $DataStage
$InstalledSizeKb = [long][System.Math]::Ceiling(
(Get-ChildItem -Recurse -File $DataStage |
Measure-Object -Property Length -Sum).Sum / 1024
)
# 3. control.tar.gz
$ControlDir = Join-Path $TmpDir 'control'
New-Item -ItemType Directory -Path $ControlDir | Out-Null
# dpkg requires LF line endings and a trailing newline in control files
$ControlLines = @(
"Package: $PackageName",
"Version: $Version",
"Architecture: $Architecture",
"Maintainer: $Maintainer",
"Installed-Size: $InstalledSizeKb",
"Homepage: $Url",
"Description: $Description",
"Priority: optional",
""
)
[System.IO.File]::WriteAllText(
(Join-Path $ControlDir 'control'),
($ControlLines -join "`n")
)
$ControlTarPath = Join-Path $TmpDir 'control.tar.gz'
& tar -czf $ControlTarPath -C $ControlDir .
if ($LASTEXITCODE -ne 0) { throw "tar (control) exited $LASTEXITCODE" }
$DataTarPath = Join-Path $TmpDir 'data.tar.gz'
& tar -czf $DataTarPath -C $DataStage .
if ($LASTEXITCODE -ne 0) { throw "tar (data) exited $LASTEXITCODE" }
# 4. Assemble .deb with ar
# Member names in the archive must be bare filenames (not paths),
# so Push-Location into TmpDir before invoking ar.
Push-Location $TmpDir
try {
& ar rc $OutputPath 'debian-binary' 'control.tar.gz' 'data.tar.gz'
if ($LASTEXITCODE -ne 0) { throw "ar exited $LASTEXITCODE" }
}
finally { Pop-Location }
}
# ------------------------------------------------------------------
# .rpm
# Built with rpmbuild. %install copies from the absolute SourceDir
# into the buildroot so no pre-staged directory tricks are needed.
# Tools required: rpmbuild (rpm-build apt package)
# ------------------------------------------------------------------
elseif ($PackageType -eq 'rpm') {
$DataStage = Join-Path $TmpDir 'data'
New-LinuxIntegrationAssets -StageRoot $DataStage
$RpmTop = Join-Path $TmpDir 'rpmbuild'
foreach ($d in 'BUILD', 'BUILDROOT', 'RPMS', 'SOURCES', 'SPECS', 'SRPMS') {
New-Item -ItemType Directory -Path (Join-Path $RpmTop $d) | Out-Null
}
$SpecPath = Join-Path $RpmTop 'SPECS' "$PackageName.spec"
# Spec uses LF line endings; here-string keeps them on Linux/macOS
$SpecText = @"
Name: $PackageName
Version: $Version
Release: $Iteration
Summary: $Description
License: GPL-3.0-or-later
URL: $Url
# The self-contained .NET runtime links against liblttng-ust.so.0, which was
# renamed to .so.1 in newer distros (Fedora 38+). Exclude it so the package
# installs on both old and new releases; .NET ships its own tracing fallback.
%global __requires_exclude ^liblttng-ust\.so
%description
$Description
%install
cp -rp $DataStage/. %{buildroot}/
%files
%defattr(-,root,root,-)
$InstallPrefix
$LauncherPath
$DesktopFilePath
$IconInstallPath
%changelog
"@
[System.IO.File]::WriteAllText($SpecPath, $SpecText)
& rpmbuild -bb `
--define "_topdir $RpmTop" `
--define "_build_name_fmt %%{NAME}-%%{VERSION}-%%{RELEASE}.%%{ARCH}.rpm" `
--define "__strip /bin/true" `
--define "__debug_install_post %{nil}" `
--define "debug_package %{nil}" `
--target "$Architecture-unknown-linux" `
$SpecPath
if ($LASTEXITCODE -ne 0) { throw "rpmbuild exited $LASTEXITCODE" }
$BuiltRpm = Get-ChildItem -Path (Join-Path $RpmTop 'RPMS') -Recurse -Filter '*.rpm' |
Select-Object -First 1
if (-not $BuiltRpm) { throw "rpmbuild produced no .rpm file" }
Move-Item -Path $BuiltRpm.FullName -Destination $OutputPath -Force
}
}
finally {
Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue
}
Write-Host "Created: $OutputPath"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Publishes UniGetUI as a NativeAOT self-contained Windows build.
.PARAMETER Configuration
Build configuration. Default: Release.
.PARAMETER Platform
Target platform. Default: x64. Supported: x64, arm64.
.PARAMETER OutputPath
Directory for the published output. Default: ./artifacts/nativeaot/win-<platform>.
.PARAMETER PublishProfileName
NativeAOT publish profile name. Default: Win-<platform>-NativeAot.
#>
[CmdletBinding()]
param(
[string] $Configuration = "Release",
[ValidateSet("x64", "arm64")]
[string] $Platform = "x64",
[string] $OutputPath = (Join-Path (Join-Path $PSScriptRoot "..") "artifacts/nativeaot/win-$Platform"),
[string] $PublishProfileName = "Win-$Platform-NativeAot"
)
$ErrorActionPreference = 'Stop'
$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$ProjectPath = Join-Path $RepoRoot "src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj"
if (Test-Path $OutputPath) {
Remove-Item $OutputPath -Recurse -Force
}
New-Item $OutputPath -ItemType Directory -Force | Out-Null
Write-Host "Publishing NativeAOT build for win-$Platform to $OutputPath" -ForegroundColor Cyan
dotnet publish $ProjectPath `
--configuration $Configuration `
--runtime "win-$Platform" `
--self-contained true `
--output $OutputPath `
/m:1 `
/p:Platform=$Platform `
/p:PublishProfile=$PublishProfileName `
/p:TrimmerSingleWarn=false `
/p:UseSharedCompilation=false `
--nologo `
--verbosity minimal
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE"
}
Write-Host "NativeAOT publish complete." -ForegroundColor Green
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Regenerates IntegrityTree.json and validates it against the current folder contents.
.PARAMETER Path
The directory whose IntegrityTree.json should be refreshed and validated.
.PARAMETER FailOnUnexpectedFiles
Fail validation if files exist in the directory tree but are not present in
IntegrityTree.json.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string] $Path,
[switch] $FailOnUnexpectedFiles
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $Path -PathType Container)) {
throw "The directory '$Path' does not exist."
}
$Path = (Resolve-Path $Path).Path
$GenerateScriptPath = Join-Path $PSScriptRoot 'generate-integrity-tree.ps1'
$VerifyScriptPath = Join-Path $PSScriptRoot 'verify-integrity-tree.ps1'
if (-not (Test-Path $GenerateScriptPath -PathType Leaf)) {
throw "Integrity tree generator not found at '$GenerateScriptPath'."
}
if (-not (Test-Path $VerifyScriptPath -PathType Leaf)) {
throw "Integrity tree validator not found at '$VerifyScriptPath'."
}
Write-Host "Refreshing integrity tree in $Path..."
& $GenerateScriptPath -Path $Path -MinOutput
$ValidationParameters = @{ Path = $Path }
if ($FailOnUnexpectedFiles) {
$ValidationParameters.FailOnUnexpectedFiles = $true
}
& $VerifyScriptPath @ValidationParameters
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Stamps version information into all required source files.
CI-friendly version stamping script for local builds and CI.
.PARAMETER Version
Semantic version string, e.g. "3.3.7" or "3.4.0-beta1".
A four-part version (X.X.X.X) is derived automatically for assembly info.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Version
)
$ErrorActionPreference = 'Stop'
$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
# Derive four-part version: 3.3.7 -> 3.3.7.0, 3.4.0-beta1 -> 3.4.0.0
$CleanVersion = ($Version -Split '-')[0] # strip prerelease tag
$Parts = $CleanVersion -Split '\.'
while ($Parts.Count -lt 4) { $Parts += '0' }
$FourPartVersion = ($Parts[0..3]) -Join '.'
Write-Host "Version name : $Version"
Write-Host "Assembly ver : $FourPartVersion"
# --- Bump build number ---
$BuildNumberFile = Join-Path $PSScriptRoot "BuildNumber"
$BuildNumber = 0
if (Test-Path $BuildNumberFile) {
$BuildNumber = [int](Get-Content $BuildNumberFile -Raw).Trim()
}
$BuildNumber++
Set-Content $BuildNumberFile $BuildNumber
Write-Host "Build number : $BuildNumber"
# --- Helper: replace lines in a file by prefix match ---
function Set-LinesByPrefix {
param(
[string] $FilePath,
[hashtable] $Replacements,
[string] $Encoding = 'utf8BOM'
)
if (-not (Test-Path $FilePath)) {
Write-Warning "File not found, skipping: $FilePath"
return
}
$lines = Get-Content $FilePath -Encoding $Encoding
$output = foreach ($line in $lines) {
$matched = $false
foreach ($prefix in $Replacements.Keys) {
if ($line.TrimStart().StartsWith($prefix)) {
$Replacements[$prefix]
$matched = $true
break
}
}
if (-not $matched) { $line }
}
$output | Set-Content $FilePath -Encoding $Encoding
}
# --- CoreData.cs ---
Set-LinesByPrefix -FilePath (Join-Path $RepoRoot "src" "UniGetUI.Core.Data" "CoreData.cs") -Replacements @{
'public const string VersionName =' = " public const string VersionName = `"$Version`"; // Do not modify this line, use file scripts/set-version.ps1"
'public const int BuildNumber =' = " public const int BuildNumber = $BuildNumber; // Do not modify this line, use file scripts/set-version.ps1"
}
# --- SharedAssemblyInfo.cs ---
Set-LinesByPrefix -FilePath (Join-Path $RepoRoot "src" "SharedAssemblyInfo.cs") -Replacements @{
'[assembly: AssemblyVersion("' = "[assembly: AssemblyVersion(`"$FourPartVersion`")]"
'[assembly: AssemblyFileVersion("' = "[assembly: AssemblyFileVersion(`"$FourPartVersion`")]"
'[assembly: AssemblyInformationalVersion("' = "[assembly: AssemblyInformationalVersion(`"$Version`")]"
}
# --- UniGetUI.iss ---
Set-LinesByPrefix -FilePath (Join-Path $RepoRoot "UniGetUI.iss") -Replacements @{
'#define MyAppVersion' = "#define MyAppVersion `"$Version`""
'VersionInfoVersion=' = "VersionInfoVersion=$FourPartVersion"
'VersionInfoProductVersion=' = "VersionInfoProductVersion=$FourPartVersion"
}
# --- app.manifest (only the assemblyIdentity version, not manifestVersion) ---
$ManifestPath = Join-Path $RepoRoot "src" "UniGetUI" "app.manifest"
if (Test-Path $ManifestPath) {
$content = Get-Content $ManifestPath -Raw -Encoding utf8BOM
$content = $content -Replace '(?s)(<assemblyIdentity\b.*?\bversion\s*=\s*")[^"]*(")', "`${1}$FourPartVersion`${2}"
Set-Content $ManifestPath $content -Encoding utf8BOM -NoNewline
}
Write-Host "Version stamped successfully."
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Code-signs binaries and installer using AzureSignTool (Azure Key Vault).
.PARAMETER BinDir
Directory containing binaries to sign (exe/dll files).
.PARAMETER InstallerPath
Path to the installer .exe to sign (optional).
.PARAMETER FileListPath
Path to a text file containing one file path per line to sign.
.PARAMETER AzureTenantId
Azure AD tenant ID.
.PARAMETER KeyVaultUrl
Azure Key Vault URL.
.PARAMETER ClientId
Azure AD application (client) ID.
.PARAMETER ClientSecret
Azure AD application client secret.
.PARAMETER CertificateName
Name of the code-signing certificate in Key Vault.
.PARAMETER TimestampServer
RFC 3161 timestamping server URL. Default: http://timestamp.digicert.com
.PARAMETER Install
Install required code-signing tools before signing.
#>
[CmdletBinding()]
param(
[string] $BinDir,
[string] $InstallerPath,
[string] $FileListPath,
[Parameter(Mandatory)]
[string] $AzureTenantId,
[Parameter(Mandatory)]
[string] $KeyVaultUrl,
[Parameter(Mandatory)]
[string] $ClientId,
[Parameter(Mandatory)]
[string] $ClientSecret,
[Parameter(Mandatory)]
[string] $CertificateName,
[string] $TimestampServer = "http://timestamp.digicert.com",
[switch] $Install
)
$ErrorActionPreference = 'Stop'
# --- Install tools if requested ---
if ($Install) {
Write-Host "Installing code-signing tools..." -ForegroundColor Cyan
dotnet tool install --global AzureSignTool 2>$null
Install-Module -Name Devolutions.Authenticode -Force -Scope CurrentUser 2>$null
# Trust test code-signing CA
$TestCertsUrl = "https://raw.githubusercontent.com/Devolutions/devolutions-authenticode/master/data/certs"
$CaCertPath = Join-Path $env:TEMP "authenticode-test-ca.crt"
Invoke-WebRequest -Uri "$TestCertsUrl/authenticode-test-ca.crt" -OutFile $CaCertPath
Import-Certificate -FilePath $CaCertPath -CertStoreLocation "cert:\LocalMachine\Root" | Out-Null
Remove-Item $CaCertPath -ErrorAction SilentlyContinue
Write-Host "Code-signing tools installed."
}
$SignParams = @(
'sign',
'-kvt', $AzureTenantId,
'-kvu', $KeyVaultUrl,
'-kvi', $ClientId,
'-kvs', $ClientSecret,
'-kvc', $CertificateName,
'-tr', $TimestampServer,
'-v'
)
function Invoke-BatchSign {
param(
[string[]] $Files
)
$Files = $Files | Where-Object { $_ -and (Test-Path $_) }
if (-not $Files -or $Files.Count -eq 0) {
Write-Warning "No files to sign."
return
}
Write-Host "Signing $($Files.Count) files..."
# Pass file paths via -ifl so we don't blow past the Windows 32K command-line limit.
$tempListPath = [System.IO.Path]::GetTempFileName()
try {
Set-Content -Path $tempListPath -Value $Files -Encoding utf8
AzureSignTool @SignParams -ifl $tempListPath
if ($LASTEXITCODE -ne 0) {
throw "AzureSignTool failed with exit code $LASTEXITCODE"
}
} finally {
Remove-Item -Path $tempListPath -ErrorAction SilentlyContinue
}
}
function Update-IntegrityTree {
param(
[string] $RootPath
)
if (-not $RootPath -or -not (Test-Path $RootPath -PathType Container)) {
return
}
$TreeRefreshScriptPath = Join-Path $PSScriptRoot "refresh-integrity-tree.ps1"
if (-not (Test-Path $TreeRefreshScriptPath -PathType Leaf)) {
Write-Warning "Integrity tree refresh script not found at $TreeRefreshScriptPath"
return
}
& $TreeRefreshScriptPath -Path $RootPath -FailOnUnexpectedFiles
if ($LASTEXITCODE -ne 0) {
throw "refresh-integrity-tree.ps1 failed with exit code $LASTEXITCODE"
}
}
# --- Sign binaries in BinDir ---
if ($FileListPath -and (Test-Path $FileListPath)) {
Write-Host "`n=== Signing binaries from list: $FileListPath ===" -ForegroundColor Cyan
$filesToSign = Get-Content $FileListPath | Where-Object { $_ -and ($_ -notmatch '^\s*$') }
Invoke-BatchSign -Files $filesToSign
} elseif ($BinDir -and (Test-Path $BinDir)) {
Write-Host "`n=== Signing binaries in $BinDir ===" -ForegroundColor Cyan
$filesToSign = Get-ChildItem -Path $BinDir -Include @("*.exe", "*.dll") -Recurse
if ($filesToSign.Count -eq 0) {
Write-Warning "No .exe or .dll files found in $BinDir"
} else {
Invoke-BatchSign -Files ($filesToSign | ForEach-Object { $_.FullName })
Update-IntegrityTree -RootPath $BinDir
Write-Host "Binary signing complete."
}
}
# --- Sign installer ---
if ($InstallerPath -and (Test-Path $InstallerPath)) {
Write-Host "`n=== Signing installer: $InstallerPath ===" -ForegroundColor Cyan
AzureSignTool @SignParams $InstallerPath
if ($LASTEXITCODE -ne 0) {
throw "AzureSignTool failed for installer with exit code $LASTEXITCODE"
}
Write-Host "Installer signing complete."
}
@@ -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
}
+410
View File
@@ -0,0 +1,410 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Maintains and validates the checked-in icon database JSON.
.DESCRIPTION
Treats WebBasedData/screenshot-database-v2.json as the source of truth,
recalculates package_count from the JSON payload, and can validate icon and
screenshot URLs while honoring invalid_urls.txt.
.EXAMPLE
./scripts/update-icons.ps1
Recomputes package_count and rewrites screenshot-database-v2.json.
.EXAMPLE
./scripts/update-icons.ps1 -Validate -MaxPackages 25
Validates the first 25 package entries from screenshot-database-v2.json.
.EXAMPLE
./scripts/update-icons.ps1 -Validate -AppendInvalidUrls
Validates all icon and screenshot URLs and appends any new failures to
WebBasedData/invalid_urls.txt.
#>
[CmdletBinding(DefaultParameterSetName = 'Sync')]
param(
[Parameter(ParameterSetName = 'Sync')]
[switch] $Sync,
[Parameter(ParameterSetName = 'Validate')]
[switch] $Validate,
[string] $JsonPath = 'WebBasedData/screenshot-database-v2.json',
[string] $InvalidUrlsPath = 'WebBasedData/invalid_urls.txt',
[switch] $AppendInvalidUrls,
[int] $MaxPackages,
[int] $MaxRetries = 2,
[int] $RetryDelayMilliseconds = 200,
[int] $RequestTimeoutSeconds = 15
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
if (-not $PSBoundParameters.ContainsKey('Sync') -and -not $PSBoundParameters.ContainsKey('Validate')) {
$Sync = $true
}
function Resolve-RepoPath {
param(
[Parameter(Mandatory)]
[string] $Path
)
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
}
return [System.IO.Path]::GetFullPath((Join-Path $RepoRoot $Path))
}
function Write-Utf8File {
param(
[Parameter(Mandatory)]
[string] $Path,
[Parameter(Mandatory)]
[AllowEmptyString()]
[string] $Content
)
$encoding = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
}
function Get-ForbiddenUrlSet {
param(
[Parameter(Mandatory)]
[string] $Path
)
$lookup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
if (-not (Test-Path -LiteralPath $Path)) {
return ,$lookup
}
foreach ($line in [System.IO.File]::ReadAllLines($Path)) {
$value = $line.Trim()
if ($value.Length -gt 0) {
[void] $lookup.Add($value)
}
}
return ,$lookup
}
function ConvertTo-ForbiddenUrlSet {
param(
[AllowNull()]
[object] $Value
)
$lookup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
if ($null -eq $Value) {
return ,$lookup
}
foreach ($item in @($Value)) {
if ($null -eq $item) {
continue
}
$text = [string] $item
if ([string]::IsNullOrWhiteSpace($text)) {
continue
}
[void] $lookup.Add($text)
}
return ,$lookup
}
function Read-IconDatabase {
param(
[Parameter(Mandatory)]
[string] $Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Icon database file not found: $Path"
}
$database = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable
if (-not ($database -is [System.Collections.IDictionary])) {
throw 'Icon database root must be a JSON object.'
}
if (-not $database.Contains('icons_and_screenshots')) {
throw 'Icon database is missing icons_and_screenshots.'
}
if (-not ($database['icons_and_screenshots'] -is [System.Collections.IDictionary])) {
throw 'icons_and_screenshots must be a JSON object.'
}
if (-not $database.Contains('package_count')) {
$database['package_count'] = [ordered]@{}
}
return $database
}
function Normalize-Entry {
param(
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Entry
)
if (-not $Entry.Contains('icon') -or $null -eq $Entry['icon']) {
$Entry['icon'] = ''
}
if (-not $Entry.Contains('images') -or $null -eq $Entry['images']) {
$Entry['images'] = @()
}
$images = @()
$seenImages = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($image in @($Entry['images'])) {
if ($null -eq $image) {
continue
}
$trimmedImage = [string] $image
if ([string]::IsNullOrWhiteSpace($trimmedImage)) {
continue
}
if ($seenImages.Add($trimmedImage)) {
$images += $trimmedImage
}
}
$Entry['icon'] = [string] $Entry['icon']
$Entry['images'] = $images
}
function Update-PackageCount {
param(
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Database,
[AllowNull()]
[object] $ForbiddenUrls
)
$ForbiddenUrls = ConvertTo-ForbiddenUrlSet -Value $ForbiddenUrls
$entries = $Database['icons_and_screenshots']
$total = 0
$done = 0
$packagesWithIcon = 0
$packagesWithScreenshot = 0
$totalScreenshots = 0
foreach ($packageId in $entries.Keys) {
$total++
$entry = $entries[$packageId]
Normalize-Entry -Entry $entry
$iconUrl = [string] $entry['icon']
$validIcon = -not [string]::IsNullOrWhiteSpace($iconUrl) -and -not $ForbiddenUrls.Contains($iconUrl)
if ($validIcon) {
$done++
$packagesWithIcon++
}
$validScreenshots = @(
$entry['images'] | Where-Object {
-not [string]::IsNullOrWhiteSpace($_) -and -not $ForbiddenUrls.Contains($_)
}
)
if ($validScreenshots.Count -gt 0) {
$packagesWithScreenshot++
$totalScreenshots += $validScreenshots.Count
}
}
$Database['package_count'] = [ordered]@{
total = $total
done = $done
packages_with_icon = $packagesWithIcon
packages_with_screenshot = $packagesWithScreenshot
total_screenshots = $totalScreenshots
}
}
function Write-IconDatabase {
param(
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Database,
[Parameter(Mandatory)]
[string] $Path
)
$json = $Database | ConvertTo-Json -Depth 100
Write-Utf8File -Path $Path -Content $json
}
function Invoke-UrlRequest {
param(
[Parameter(Mandatory)]
[uri] $Uri,
[Parameter(Mandatory)]
[System.Net.Http.HttpClient] $Client,
[Parameter(Mandatory)]
[int] $MaxRetries,
[Parameter(Mandatory)]
[int] $RetryDelayMilliseconds
)
$attempt = 0
do {
try {
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Uri)
$response = $Client.Send($request, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead)
return $response
}
catch {
if ($attempt -ge $MaxRetries) {
throw
}
$attempt++
Start-Sleep -Milliseconds $RetryDelayMilliseconds
}
} while ($true)
}
function Test-IconUrls {
param(
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Database,
[AllowNull()]
[object] $ForbiddenUrls,
[Parameter(Mandatory)]
[string] $InvalidUrlsPath,
[switch] $AppendInvalidUrls,
[int] $MaxPackages,
[int] $MaxRetries,
[int] $RetryDelayMilliseconds,
[int] $RequestTimeoutSeconds
)
$ForbiddenUrls = ConvertTo-ForbiddenUrlSet -Value $ForbiddenUrls
$client = [System.Net.Http.HttpClient]::new()
$client.Timeout = [TimeSpan]::FromSeconds($RequestTimeoutSeconds)
$client.DefaultRequestHeaders.UserAgent.ParseAdd('UniGetUI-IconValidator/2.0')
$newInvalidUrls = [System.Collections.Generic.List[string]]::new()
$inspectedPackages = 0
$testedUrls = 0
try {
foreach ($packageId in $Database['icons_and_screenshots'].Keys) {
if ($MaxPackages -gt 0 -and $inspectedPackages -ge $MaxPackages) {
break
}
$entry = $Database['icons_and_screenshots'][$packageId]
Normalize-Entry -Entry $entry
$urls = @()
if (-not [string]::IsNullOrWhiteSpace($entry['icon'])) {
$urls += [string] $entry['icon']
}
$urls += @($entry['images'])
foreach ($url in $urls) {
if ([string]::IsNullOrWhiteSpace($url) -or $ForbiddenUrls.Contains($url)) {
continue
}
$testedUrls++
try {
$response = Invoke-UrlRequest -Uri ([uri] $url) -Client $client -MaxRetries $MaxRetries -RetryDelayMilliseconds $RetryDelayMilliseconds
try {
$statusCode = [int] $response.StatusCode
if ($statusCode -ne 200 -and $statusCode -ne 403) {
[void] $newInvalidUrls.Add($url)
Write-Warning "[$packageId] $url returned HTTP $statusCode"
}
}
finally {
$response.Dispose()
}
}
catch {
[void] $newInvalidUrls.Add($url)
Write-Warning "[$packageId] Failed to fetch $url. $($_.Exception.Message)"
}
}
$inspectedPackages++
}
}
finally {
$client.Dispose()
}
$distinctInvalidUrls = $newInvalidUrls | Select-Object -Unique
if ($AppendInvalidUrls -and $distinctInvalidUrls.Count -gt 0) {
$directory = Split-Path -Parent $InvalidUrlsPath
if (-not [string]::IsNullOrWhiteSpace($directory)) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
}
$existing = Get-ForbiddenUrlSet -Path $InvalidUrlsPath
$linesToAppend = @()
foreach ($url in $distinctInvalidUrls) {
if ($existing.Add($url)) {
$linesToAppend += $url
}
}
if ($linesToAppend.Count -gt 0) {
[System.IO.File]::AppendAllLines(
$InvalidUrlsPath,
$linesToAppend,
[System.Text.UTF8Encoding]::new($false)
)
}
}
Write-Host "Packages inspected: $inspectedPackages"
Write-Host "URLs tested: $testedUrls"
Write-Host "New invalid URLs found: $($distinctInvalidUrls.Count)"
}
$resolvedJsonPath = Resolve-RepoPath -Path $JsonPath
$resolvedInvalidUrlsPath = Resolve-RepoPath -Path $InvalidUrlsPath
$database = Read-IconDatabase -Path $resolvedJsonPath
$forbiddenUrls = Get-ForbiddenUrlSet -Path $resolvedInvalidUrlsPath
Update-PackageCount -Database $database -ForbiddenUrls (,$forbiddenUrls)
if ($Validate) {
Test-IconUrls -Database $database -ForbiddenUrls (,$forbiddenUrls) -InvalidUrlsPath $resolvedInvalidUrlsPath -AppendInvalidUrls:$AppendInvalidUrls -MaxPackages $MaxPackages -MaxRetries $MaxRetries -RetryDelayMilliseconds $RetryDelayMilliseconds -RequestTimeoutSeconds $RequestTimeoutSeconds
return
}
Write-IconDatabase -Database $database -Path $resolvedJsonPath
Write-Host "Updated icon database counts in $resolvedJsonPath"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Validates a generated IntegrityTree.json against the files in a directory.
.DESCRIPTION
This mirrors UniGetUI runtime integrity verification and can optionally fail
if the directory contains files that are not listed in IntegrityTree.json.
.PARAMETER Path
The directory containing IntegrityTree.json and the files to validate.
.PARAMETER FailOnUnexpectedFiles
Fail validation if files exist in the directory tree but are not present in
IntegrityTree.json.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0)]
[string] $Path,
[switch] $FailOnUnexpectedFiles
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $Path -PathType Container)) {
throw "The directory '$Path' does not exist."
}
$Path = (Resolve-Path $Path).Path
$IntegrityTreePath = Join-Path $Path 'IntegrityTree.json'
if (-not (Test-Path $IntegrityTreePath -PathType Leaf)) {
throw "IntegrityTree.json was not found in '$Path'."
}
$rawData = Get-Content $IntegrityTreePath -Raw
try {
$data = ConvertFrom-Json $rawData -AsHashtable
}
catch {
throw "IntegrityTree.json is not valid JSON: $($_.Exception.Message)"
}
if ($null -eq $data) {
throw 'IntegrityTree.json did not deserialize into a JSON object.'
}
$missingFiles = New-Object System.Collections.Generic.List[string]
$mismatchedFiles = New-Object System.Collections.Generic.List[string]
$unexpectedFiles = New-Object System.Collections.Generic.List[string]
$expectedFiles = @{}
foreach ($entry in $data.GetEnumerator()) {
$relativePath = [string] $entry.Key
$expectedHash = [string] $entry.Value
$expectedFiles[$relativePath] = $true
$fullPath = Join-Path $Path $relativePath
if (-not (Test-Path $fullPath -PathType Leaf)) {
$missingFiles.Add($relativePath)
continue
}
$currentHash = (Get-FileHash $fullPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($currentHash -ne $expectedHash.ToLowerInvariant()) {
$mismatchedFiles.Add("$relativePath|expected=$expectedHash|got=$currentHash")
}
}
if ($FailOnUnexpectedFiles) {
Get-ChildItem $Path -Recurse -File | ForEach-Object {
$relativePath = $_.FullName.Substring($Path.Length).TrimStart('\', '/') -replace '\\', '/'
if ($relativePath -eq 'IntegrityTree.json') {
return
}
if (-not $expectedFiles.ContainsKey($relativePath)) {
$unexpectedFiles.Add($relativePath)
}
}
}
if ($missingFiles.Count -or $mismatchedFiles.Count -or $unexpectedFiles.Count) {
if ($missingFiles.Count) {
Write-Error "Missing files listed in IntegrityTree.json:`n - $($missingFiles -join "`n - ")"
}
if ($mismatchedFiles.Count) {
Write-Error "Files with mismatched SHA256 values:`n - $($mismatchedFiles -join "`n - ")"
}
if ($unexpectedFiles.Count) {
Write-Error "Unexpected files not present in IntegrityTree.json:`n - $($unexpectedFiles -join "`n - ")"
}
throw 'Integrity tree validation failed.'
}
$validatedFileCount = $data.Count
Write-Host "Integrity tree validation succeeded for $validatedFileCount file(s) in $Path"