chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
106
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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('&', '&').Replace('<', '<').Replace('>', '>')
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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> $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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user