chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: translation-diff-export
|
||||
description: Compares UniGetUI JSON locale files against English, identifies untranslated or source-changed keys, and generates patch, reference, and handoff files for a target language. Use when the user asks to export missing translations, untranslated strings, i18n diffs, or locale-file changes.
|
||||
---
|
||||
|
||||
# translation diff export
|
||||
|
||||
Use this skill when you need a small UniGetUI translation patch instead of sending a full language JSON file for review or translation.
|
||||
|
||||
It exports only the active untranslated or source-changed strings, creates the translator handoff files, and prepares the patch set needed by `translation-diff-translate` and `translation-diff-import`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- PowerShell 7 (`pwsh`).
|
||||
- `git` available on `PATH`.
|
||||
- `cirup` available on `PATH`.
|
||||
|
||||
When source strings changed, run `translation-source-sync` first so `lang_en.json` reflects current source usage before exporting downstream translation patches.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/export-translation-diff.ps1`: Exports JSON patch artifacts and generates a translation handoff prompt.
|
||||
- `scripts/test-translation-diff.ps1`: Runs a local end-to-end smoke test against the checked-in UniGetUI language files.
|
||||
|
||||
Use this skill's wrapper scripts first; the downstream translate and import steps are documented in [translation-diff-translate](../translation-diff-translate/SKILL.md) and [translation-diff-import](../translation-diff-import/SKILL.md).
|
||||
|
||||
## Usage
|
||||
|
||||
Export untranslated French strings only:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-diff-export/scripts/export-translation-diff.ps1 \
|
||||
-NeutralJson ./src/Languages/lang_en.json \
|
||||
-TargetJson ./src/Languages/lang_fr.json \
|
||||
-Language fr
|
||||
```
|
||||
|
||||
Export untranslated French strings plus English source values changed since `origin/main`:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-diff-export/scripts/export-translation-diff.ps1 \
|
||||
-NeutralJson ./src/Languages/lang_en.json \
|
||||
-TargetJson ./src/Languages/lang_fr.json \
|
||||
-Language fr \
|
||||
-BaseRef origin/main
|
||||
```
|
||||
|
||||
Optional parameters:
|
||||
|
||||
- `-OutputDir` (default: `generated/translation-diff-export`)
|
||||
- `-KeepIntermediate`
|
||||
|
||||
Run the built-in smoke test:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-diff-export/scripts/test-translation-diff.ps1
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
For `lang_en.json` and language `fr`, the script generates:
|
||||
|
||||
- `generated/translation-diff-export/lang.diff.fr.source.json`
|
||||
- `generated/translation-diff-export/lang.diff.fr.translated.json`
|
||||
- `generated/translation-diff-export/lang.diff.fr.reference.json`
|
||||
- `generated/translation-diff-export/lang.diff.fr.prompt.md`
|
||||
|
||||
If `-KeepIntermediate` is used, git-baseline snapshots are kept under `generated/translation-diff-export/tmp/`.
|
||||
|
||||
The smoke test writes its temporary artifacts under `generated/translation-diff-export-demo/`.
|
||||
|
||||
## Validate The Export
|
||||
|
||||
After export, confirm the patch is usable before handing it off:
|
||||
|
||||
1. Check that `.source.json` is not empty unless you expected no work for that language.
|
||||
2. Confirm `.translated.json` is sparse and does not contain copied English placeholders for unfinished keys.
|
||||
3. If the patch should include recent English changes, rerun with `-BaseRef` and compare the resulting `.source.json`.
|
||||
4. Run the smoke test if you changed the export workflow itself.
|
||||
|
||||
## Hand Off To Translate
|
||||
|
||||
After exporting the patch, use the generated `.prompt.md` file with [translation-diff-translate](../translation-diff-translate/SKILL.md) to update the sparse translated working copy.
|
||||
|
||||
## Hand Off To Import
|
||||
|
||||
After translating the patch, merge it back into the full language file with [translation-diff-import](../translation-diff-import/SKILL.md):
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-diff-import/scripts/import-translation-diff.ps1 \
|
||||
-TranslatedPatch ./generated/translation-diff-export/lang.diff.fr.translated.json \
|
||||
-SourcePatch ./generated/translation-diff-export/lang.diff.fr.source.json \
|
||||
-TargetJson ./src/Languages/lang_fr.json \
|
||||
-NeutralJson ./src/Languages/lang_en.json \
|
||||
-OutputJson ./src/Languages/lang_fr.merged.json
|
||||
```
|
||||
|
||||
Keep the `.translated.json` file sparse. If a key is not translated yet, leave it out instead of copying the English source value into the working copy.
|
||||
@@ -0,0 +1,3 @@
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
. (Join-Path $PSScriptRoot '..\..\..\..\scripts\translation\Languages\TranslationJsonTools.ps1')
|
||||
@@ -0,0 +1,508 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$NeutralJson,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetJson,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Language,
|
||||
|
||||
[string]$BaseRef,
|
||||
|
||||
[string]$OutputDir = 'generated/translation-diff-export',
|
||||
|
||||
[switch]$ActiveOnly,
|
||||
|
||||
[switch]$KeepIntermediate
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
. (Join-Path $PSScriptRoot 'TranslationDiff.JsonTools.ps1')
|
||||
|
||||
function Get-GitJsonMap {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RepositoryRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BaseRef,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RepoRelativePath
|
||||
)
|
||||
|
||||
$gitSpec = '{0}:{1}' -f $BaseRef, $RepoRelativePath
|
||||
$rawContent = & git -C $RepositoryRoot show $gitSpec 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$jsonText = [string]::Join([Environment]::NewLine, @($rawContent))
|
||||
if ([string]::IsNullOrWhiteSpace($jsonText)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$tempPath = Join-Path $env:TEMP ('translation-diff-{0}.json' -f [System.Guid]::NewGuid().ToString('N'))
|
||||
try {
|
||||
New-Utf8File -Path $tempPath -Content $jsonText
|
||||
return Read-OrderedJsonMap -Path $tempPath
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Path $tempPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CirupKeySet {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
$rows = Invoke-CirupJson -Arguments $Arguments
|
||||
$keys = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::Ordinal)
|
||||
foreach ($row in $rows) {
|
||||
if (-not ($row -is [System.Collections.IDictionary]) -or -not $row.Contains('name')) {
|
||||
throw 'cirup returned an unexpected JSON payload.'
|
||||
}
|
||||
|
||||
[void]$keys.Add([string]$row['name'])
|
||||
}
|
||||
|
||||
return $keys
|
||||
}
|
||||
|
||||
function Get-EmptyTargetKeys {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$SourceMap,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$TargetMap
|
||||
)
|
||||
|
||||
$keys = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::Ordinal)
|
||||
foreach ($entry in $SourceMap.GetEnumerator()) {
|
||||
$key = [string]$entry.Key
|
||||
if (-not $TargetMap.Contains($key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace([string]$TargetMap[$key])) {
|
||||
[void]$keys.Add($key)
|
||||
}
|
||||
}
|
||||
|
||||
return $keys
|
||||
}
|
||||
|
||||
function Test-IntentionalSourceEqualValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LanguageCode,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$SourceValue,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[AllowEmptyString()]
|
||||
[string]$TargetValue = ''
|
||||
)
|
||||
|
||||
if ($SourceValue -in @(
|
||||
'OK',
|
||||
'UniGetUI',
|
||||
'UniGetUI - {0} {1}'
|
||||
)) {
|
||||
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 '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
|
||||
}
|
||||
|
||||
. ([System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..\..\scripts\translation\Languages\IntentionalSourceEqualValues.ps1')))
|
||||
|
||||
function Sync-TranslatedWorkingCopy {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LanguageCode,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$CurrentSourceMap,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TranslatedPatchPath,
|
||||
|
||||
[System.Collections.IDictionary]$PreviousSourceMap
|
||||
)
|
||||
|
||||
$translatedMap = Read-OrderedJsonMap -Path $TranslatedPatchPath
|
||||
$syncedMap = New-OrderedStringMap
|
||||
|
||||
foreach ($entry in $CurrentSourceMap.GetEnumerator()) {
|
||||
$key = [string]$entry.Key
|
||||
$sourceValue = [string]$entry.Value
|
||||
if (-not $translatedMap.Contains($key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$translatedValue = [string]$translatedMap[$key]
|
||||
if ([string]::IsNullOrWhiteSpace($translatedValue)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($translatedValue -ceq $sourceValue) {
|
||||
if (-not (Test-IntentionalSourceEqualValue -LanguageCode $LanguageCode -SourceValue $sourceValue -TargetValue $translatedValue)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -ne $PreviousSourceMap -and $PreviousSourceMap.Contains($key)) {
|
||||
if ($translatedValue -ceq [string]$PreviousSourceMap[$key]) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
$syncedMap[$key] = $translatedValue
|
||||
}
|
||||
|
||||
Write-OrderedJsonMap -Path $TranslatedPatchPath -Map $syncedMap
|
||||
}
|
||||
|
||||
function Get-FilteredPatchMap {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$Map,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$NeutralMap,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[switch]$ActiveOnly
|
||||
)
|
||||
|
||||
if (-not $ActiveOnly.IsPresent) {
|
||||
return $Map
|
||||
}
|
||||
|
||||
$activeKeys = Split-TranslationMapAtBoundary -Map $NeutralMap
|
||||
$filteredMap = New-OrderedStringMap
|
||||
foreach ($entry in $activeKeys.ActiveMap.GetEnumerator()) {
|
||||
$key = [string]$entry.Key
|
||||
if ($Map.Contains($key)) {
|
||||
$filteredMap[$key] = [string]$Map[$key]
|
||||
}
|
||||
}
|
||||
|
||||
return $filteredMap
|
||||
}
|
||||
|
||||
Assert-Command -Name 'git'
|
||||
Assert-Command -Name 'cirup'
|
||||
|
||||
$neutralPath = Get-FullPath -Path $NeutralJson
|
||||
$targetPath = Get-FullPath -Path $TargetJson
|
||||
if (-not (Test-Path -Path $neutralPath -PathType Leaf)) {
|
||||
throw "Neutral JSON file not found: $neutralPath"
|
||||
}
|
||||
|
||||
$languageCode = $Language.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($languageCode)) {
|
||||
throw 'Language must not be empty.'
|
||||
}
|
||||
|
||||
$languagesReferencePath = Get-LanguagesReferencePath -NeutralJsonPath $neutralPath
|
||||
Assert-LanguageCodeKnown -LanguagesReferencePath $languagesReferencePath -LanguageCode $languageCode
|
||||
|
||||
$outputRoot = Get-FullPath -Path $OutputDir
|
||||
$tmpRoot = Join-Path $outputRoot 'tmp'
|
||||
New-Item -Path $outputRoot -ItemType Directory -Force | Out-Null
|
||||
New-Item -Path $tmpRoot -ItemType Directory -Force | Out-Null
|
||||
|
||||
$cirupTargetPath = $targetPath
|
||||
if (-not (Test-Path -Path $targetPath -PathType Leaf)) {
|
||||
$cirupTargetPath = Join-Path $tmpRoot 'target.empty.json'
|
||||
Write-OrderedJsonMap -Path $cirupTargetPath -Map (New-OrderedStringMap)
|
||||
}
|
||||
|
||||
$baseName = Get-PatchBaseName -NeutralJsonPath $neutralPath
|
||||
$sourcePatchPath = Join-Path $outputRoot ('{0}.diff.{1}.source.json' -f $baseName, $languageCode)
|
||||
$translatedPatchPath = Join-Path $outputRoot ('{0}.diff.{1}.translated.json' -f $baseName, $languageCode)
|
||||
$referencePatchPath = Join-Path $outputRoot ('{0}.diff.{1}.reference.json' -f $baseName, $languageCode)
|
||||
$promptPath = Join-Path $outputRoot ('{0}.diff.{1}.prompt.md' -f $baseName, $languageCode)
|
||||
|
||||
$neutralMap = Read-OrderedJsonMap -Path $neutralPath
|
||||
$targetMap = Read-OrderedJsonMap -Path $targetPath
|
||||
$baseSummary = 'No git baseline was provided. The patch contains untranslated UniGetUI entries only.'
|
||||
$changedSourceKeys = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::Ordinal)
|
||||
$missingTargetKeys = Get-CirupKeySet -Arguments @('file-diff', $neutralPath, $cirupTargetPath)
|
||||
$rawEqualEnglishKeys = Get-CirupKeySet -Arguments @('file-intersect', $neutralPath, $cirupTargetPath)
|
||||
$equalEnglishKeys = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::Ordinal)
|
||||
$rawEqualEnglishKeys | ForEach-Object {
|
||||
$key = [string]$_
|
||||
if (-not (Test-IntentionalSourceEqualValue -LanguageCode $Language -SourceValue ([string]$neutralMap[$key]) -TargetValue ([string]$targetMap[$key]))) {
|
||||
[void]$equalEnglishKeys.Add($key)
|
||||
}
|
||||
}
|
||||
$emptyTargetKeys = Get-EmptyTargetKeys -SourceMap $neutralMap -TargetMap $targetMap
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($BaseRef)) {
|
||||
$repoRoot = Get-RepositoryRoot -WorkingDirectory (Split-Path -Path $neutralPath -Parent)
|
||||
$repoRelativeNeutralPath = Get-RepoRelativePath -RepositoryRoot $repoRoot -FilePath $neutralPath
|
||||
$baseSourceMap = Get-GitJsonMap -RepositoryRoot $repoRoot -BaseRef $BaseRef -RepoRelativePath $repoRelativeNeutralPath
|
||||
$safeBaseRef = Get-SafeLabel -Value $BaseRef
|
||||
$baseSnapshotPath = Join-Path $tmpRoot ('{0}.baseline.{1}.json' -f $baseName, $safeBaseRef)
|
||||
|
||||
if ($null -ne $baseSourceMap) {
|
||||
Write-OrderedJsonMap -Path $baseSnapshotPath -Map $baseSourceMap
|
||||
$baseSummary = "Git baseline '$BaseRef' was used to include keys that are new or whose English value changed since that revision."
|
||||
}
|
||||
else {
|
||||
Write-OrderedJsonMap -Path $baseSnapshotPath -Map (New-OrderedStringMap)
|
||||
$baseSummary = "Git baseline '$BaseRef' did not contain the neutral JSON file, so all current English entries were treated as new for the changed-source delta."
|
||||
}
|
||||
|
||||
$changedSourceKeys = Get-CirupKeySet -Arguments @('diff-with-base', $baseSnapshotPath, $neutralPath, $neutralPath)
|
||||
}
|
||||
|
||||
$sourcePatchMap = New-OrderedStringMap
|
||||
$referencePatchMap = New-OrderedStringMap
|
||||
|
||||
$keysToTranslate = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::Ordinal)
|
||||
foreach ($key in $missingTargetKeys) {
|
||||
[void]$keysToTranslate.Add([string]$key)
|
||||
}
|
||||
|
||||
foreach ($key in $equalEnglishKeys) {
|
||||
[void]$keysToTranslate.Add([string]$key)
|
||||
}
|
||||
|
||||
foreach ($key in $emptyTargetKeys) {
|
||||
[void]$keysToTranslate.Add([string]$key)
|
||||
}
|
||||
|
||||
foreach ($key in $changedSourceKeys) {
|
||||
[void]$keysToTranslate.Add([string]$key)
|
||||
}
|
||||
|
||||
foreach ($entry in $neutralMap.GetEnumerator()) {
|
||||
$key = [string]$entry.Key
|
||||
$sourceValue = [string]$entry.Value
|
||||
|
||||
if ($keysToTranslate.Contains($key)) {
|
||||
$sourcePatchMap[$key] = $sourceValue
|
||||
continue
|
||||
}
|
||||
|
||||
if ($targetMap.Contains($key)) {
|
||||
$targetValue = [string]$targetMap[$key]
|
||||
if (-not [string]::IsNullOrWhiteSpace($targetValue) -and $targetValue -cne $sourceValue) {
|
||||
$referencePatchMap[$key] = $targetValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$sourcePatchMap = Get-FilteredPatchMap -Map $sourcePatchMap -NeutralMap $neutralMap -ActiveOnly:$ActiveOnly
|
||||
$referencePatchMap = Get-FilteredPatchMap -Map $referencePatchMap -NeutralMap $neutralMap -ActiveOnly:$ActiveOnly
|
||||
|
||||
if (Test-Path -Path $sourcePatchPath -PathType Leaf) {
|
||||
$previousSourceMap = Read-OrderedJsonMap -Path $sourcePatchPath
|
||||
}
|
||||
else {
|
||||
$previousSourceMap = $null
|
||||
}
|
||||
|
||||
Write-OrderedJsonMap -Path $sourcePatchPath -Map $sourcePatchMap
|
||||
Write-OrderedJsonMap -Path $referencePatchPath -Map $referencePatchMap
|
||||
Sync-TranslatedWorkingCopy -LanguageCode $languageCode -CurrentSourceMap $sourcePatchMap -TranslatedPatchPath $translatedPatchPath -PreviousSourceMap $previousSourceMap
|
||||
|
||||
$mergedTargetPath = Join-Path ([System.IO.Path]::GetDirectoryName($targetPath)) ('{0}.merged.json' -f [System.IO.Path]::GetFileNameWithoutExtension($targetPath))
|
||||
$skillsRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
|
||||
$translateHandoffScript = Join-Path $skillsRoot 'translation-diff-translate\scripts\write-translation-handoff.ps1'
|
||||
$validationScript = Join-Path $skillsRoot 'translation-diff-import\scripts\validate-language-file.ps1'
|
||||
if (-not (Test-Path -Path $translateHandoffScript -PathType Leaf)) {
|
||||
throw "Translation handoff script not found: $translateHandoffScript"
|
||||
}
|
||||
|
||||
& $translateHandoffScript -BaseName $baseName -Language $languageCode -SourcePatch $sourcePatchPath -TranslatedPatch $translatedPatchPath -ReferencePatch $referencePatchPath -TargetJson $targetPath -NeutralJson $neutralPath -MergedTargetJson $mergedTargetPath -ValidationScript $validationScript -OutputPrompt $promptPath -BaseSummary $baseSummary
|
||||
|
||||
if (-not $KeepIntermediate.IsPresent) {
|
||||
Remove-Item -Path $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Output "Created source patch: $sourcePatchPath"
|
||||
Write-Output "Refreshed translated working copy: $translatedPatchPath"
|
||||
Write-Output "Created reference patch: $referencePatchPath"
|
||||
Write-Output "Created translation handoff prompt: $promptPath"
|
||||
if ($ActiveOnly.IsPresent) {
|
||||
Write-Output 'Patch scope: active translation keys only'
|
||||
}
|
||||
if ($KeepIntermediate.IsPresent) {
|
||||
Write-Output "Kept intermediate files under: $tmpRoot"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
param(
|
||||
[string]$Language = 'fr',
|
||||
|
||||
[string]$OutputRoot = 'generated/translation-diff-export-demo'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
. (Join-Path $PSScriptRoot 'TranslationDiff.JsonTools.ps1')
|
||||
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..\..'))
|
||||
$neutralPath = Join-Path $repoRoot 'src\Languages\lang_en.json'
|
||||
$targetPath = Join-Path $repoRoot ('src\Languages\lang_{0}.json' -f $Language)
|
||||
$exportScript = Join-Path $PSScriptRoot 'export-translation-diff.ps1'
|
||||
$importScript = Join-Path $repoRoot '.agents\skills\translation-diff-import\scripts\import-translation-diff.ps1'
|
||||
$validateScript = Join-Path $repoRoot '.agents\skills\translation-diff-import\scripts\validate-language-file.ps1'
|
||||
|
||||
if (-not (Test-Path -Path $neutralPath -PathType Leaf)) {
|
||||
throw "Neutral language file not found: $neutralPath"
|
||||
}
|
||||
|
||||
& $exportScript -NeutralJson $neutralPath -TargetJson $targetPath -Language $Language -OutputDir $OutputRoot
|
||||
|
||||
$baseName = Get-PatchBaseName -NeutralJsonPath $neutralPath
|
||||
$sourcePatchPath = Join-Path (Get-FullPath -Path $OutputRoot) ('{0}.diff.{1}.source.json' -f $baseName, $Language)
|
||||
$translatedPatchPath = Join-Path (Get-FullPath -Path $OutputRoot) ('{0}.diff.{1}.translated.json' -f $baseName, $Language)
|
||||
$mergedTargetPath = Join-Path (Get-FullPath -Path $OutputRoot) ('lang_{0}.merged.smoke.json' -f $Language)
|
||||
|
||||
$sourcePatchMap = Read-OrderedJsonMap -Path $sourcePatchPath
|
||||
if ($sourcePatchMap.Count -eq 0) {
|
||||
throw 'Smoke test patch is empty. Pick a target language with untranslated entries to exercise the workflow.'
|
||||
}
|
||||
|
||||
$translatedPatchMap = New-OrderedStringMap
|
||||
$firstEntry = $sourcePatchMap.GetEnumerator() | Select-Object -First 1
|
||||
$translatedPatchMap[[string]$firstEntry.Key] = '[smoke {0}] {1}' -f $Language, [string]$firstEntry.Value
|
||||
Write-OrderedJsonMap -Path $translatedPatchPath -Map $translatedPatchMap
|
||||
|
||||
& $importScript -TranslatedPatch $translatedPatchPath -SourcePatch $sourcePatchPath -TargetJson $targetPath -NeutralJson $neutralPath -OutputJson $mergedTargetPath
|
||||
|
||||
& $validateScript -NeutralJson $neutralPath -TargetJson $mergedTargetPath -PatchJson $sourcePatchPath
|
||||
|
||||
Write-Output "Smoke test completed successfully. Merged output: $mergedTargetPath"
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: translation-diff-import
|
||||
description: Merges translated key-value pairs from a UniGetUI JSON localization patch back into the full language file and validates the merged result. Use when the user asks to apply, merge, or import a translation patch.
|
||||
---
|
||||
|
||||
# translation diff import
|
||||
|
||||
Use this skill after the export skill produced a `.source.json`, `.translated.json`, and `.reference.json` set and you want to merge the translated working copy back into the full target-language JSON file.
|
||||
|
||||
See [translation-diff-export](../translation-diff-export/SKILL.md) for the patch-generation step that produces the expected inputs.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- PowerShell 7 (`pwsh`).
|
||||
- `cirup` available on `PATH`.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/import-translation-diff.ps1`: Validates and merges a translated patch into the full language file.
|
||||
- `scripts/validate-language-file.ps1`: Validates placeholder, token, HTML-fragment, and newline parity for either a full language file or just the active patch keys against `lang_en.json`.
|
||||
|
||||
## Usage
|
||||
|
||||
Import the translated French working copy from the export skill into the full French JSON file:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-diff-import/scripts/import-translation-diff.ps1 \
|
||||
-TranslatedPatch ./generated/translation-diff-export/lang.diff.fr.translated.json \
|
||||
-SourcePatch ./generated/translation-diff-export/lang.diff.fr.source.json \
|
||||
-TargetJson ./src/Languages/lang_fr.json \
|
||||
-NeutralJson ./src/Languages/lang_en.json \
|
||||
-OutputJson ./src/Languages/lang_fr.merged.json
|
||||
```
|
||||
|
||||
Validate the merged output:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-diff-import/scripts/validate-language-file.ps1 \
|
||||
-NeutralJson ./src/Languages/lang_en.json \
|
||||
-TargetJson ./src/Languages/lang_fr.merged.json \
|
||||
-PatchJson ./generated/translation-diff-export/lang.diff.fr.source.json
|
||||
```
|
||||
|
||||
Optional parameters:
|
||||
|
||||
- `-OutputDir` (default: `generated/translation-diff-import`)
|
||||
- `-AllowUnchangedValues`
|
||||
- `-KeepIntermediate`
|
||||
|
||||
Recommended input mapping from the export skill:
|
||||
|
||||
- `-TranslatedPatch`: `generated/translation-diff-export/lang.diff.fr.translated.json`
|
||||
- `-SourcePatch`: `generated/translation-diff-export/lang.diff.fr.source.json`
|
||||
|
||||
## Output
|
||||
|
||||
- A merged `.json` file that contains both the previously translated entries and the imported patch values.
|
||||
- If `-KeepIntermediate` is used, patch snapshots are preserved under `generated/translation-diff-import/tmp/`.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- If the target JSON file does not exist yet, the script can create an output from the translated patch alone.
|
||||
- When `-SourcePatch` is provided, the script validates translated keys, placeholder tokens, HTML-like fragments, newline counts, and likely untranslated values before delegating the merge to `cirup` while allowing missing keys for partial progress.
|
||||
- The import skill expects `.translated.json` to remain sparse. Untranslated keys should be omitted instead of copied in English unless you intentionally bypass that check with `-AllowUnchangedValues`.
|
||||
@@ -0,0 +1,168 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TranslatedPatch,
|
||||
|
||||
[string]$SourcePatch,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetJson,
|
||||
|
||||
[string]$NeutralJson,
|
||||
|
||||
[string]$OutputJson,
|
||||
|
||||
[string]$OutputDir = 'generated/translation-diff-import',
|
||||
|
||||
[switch]$AllowUnchangedValues,
|
||||
|
||||
[switch]$KeepIntermediate
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
. (Join-Path $PSScriptRoot '..\..\translation-diff-export\scripts\TranslationDiff.JsonTools.ps1')
|
||||
. ([System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..\..\scripts\translation\Languages\IntentionalSourceEqualValues.ps1')))
|
||||
|
||||
function Assert-PatchCompatibility {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$SourceMap,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$TranslatedMap,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LanguageCode,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[bool]$AllowUnchanged
|
||||
)
|
||||
|
||||
foreach ($entry in $TranslatedMap.GetEnumerator()) {
|
||||
$key = [string]$entry.Key
|
||||
$translatedValue = [string]$entry.Value
|
||||
if (-not $SourceMap.Contains($key)) {
|
||||
throw "Translated patch contains unexpected key '$key'."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($translatedValue)) {
|
||||
throw "Translated patch contains an empty value for key '$key'."
|
||||
}
|
||||
|
||||
$sourceValue = [string]$SourceMap[$key]
|
||||
Assert-TranslationStructure -SourceValue $sourceValue -TranslatedValue $translatedValue -Key $key
|
||||
|
||||
if (-not $AllowUnchanged -and $translatedValue -ceq $sourceValue -and -not (Test-IntentionalSourceEqualValue -LanguageCode $LanguageCode -SourceValue $sourceValue -TargetValue $translatedValue)) {
|
||||
throw "Translated patch still contains the English source value for key '$key'. Use -AllowUnchangedValues only when that is intentional."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Merge-LanguageMaps {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$NeutralMap,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$TargetMap,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$TranslatedPatchMap
|
||||
)
|
||||
|
||||
$mergedMap = New-OrderedStringMap
|
||||
|
||||
foreach ($entry in $NeutralMap.GetEnumerator()) {
|
||||
$key = [string]$entry.Key
|
||||
if ($TranslatedPatchMap.Contains($key)) {
|
||||
$mergedMap[$key] = [string]$TranslatedPatchMap[$key]
|
||||
continue
|
||||
}
|
||||
|
||||
if ($TargetMap.Contains($key)) {
|
||||
$mergedMap[$key] = [string]$TargetMap[$key]
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($entry in $TargetMap.GetEnumerator()) {
|
||||
$key = [string]$entry.Key
|
||||
if (-not $NeutralMap.Contains($key) -and -not $mergedMap.Contains($key)) {
|
||||
$mergedMap[$key] = [string]$entry.Value
|
||||
}
|
||||
}
|
||||
|
||||
return $mergedMap
|
||||
}
|
||||
|
||||
$translatedPatchPath = Get-FullPath -Path $TranslatedPatch
|
||||
if (-not (Test-Path -Path $translatedPatchPath -PathType Leaf)) {
|
||||
throw "Translated patch file not found: $translatedPatchPath"
|
||||
}
|
||||
|
||||
$targetPath = Get-FullPath -Path $TargetJson
|
||||
$targetMap = Read-OrderedJsonMap -Path $targetPath
|
||||
$translatedPatchMap = Read-OrderedJsonMap -Path $translatedPatchPath
|
||||
$targetBaseName = [System.IO.Path]::GetFileNameWithoutExtension($targetPath)
|
||||
$languageCode = if ($targetBaseName.StartsWith('lang_', [System.StringComparison]::Ordinal)) {
|
||||
$targetBaseName.Substring(5)
|
||||
}
|
||||
else {
|
||||
''
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($NeutralJson)) {
|
||||
$NeutralJson = Join-Path (Split-Path -Path $targetPath -Parent) 'lang_en.json'
|
||||
}
|
||||
|
||||
$neutralPath = Get-FullPath -Path $NeutralJson
|
||||
if (-not (Test-Path -Path $neutralPath -PathType Leaf)) {
|
||||
throw "Neutral JSON file not found: $neutralPath"
|
||||
}
|
||||
|
||||
$neutralMap = Read-OrderedJsonMap -Path $neutralPath
|
||||
$sourceMap = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($SourcePatch)) {
|
||||
$sourcePatchPath = Get-FullPath -Path $SourcePatch
|
||||
if (-not (Test-Path -Path $sourcePatchPath -PathType Leaf)) {
|
||||
throw "Source patch file not found: $sourcePatchPath"
|
||||
}
|
||||
|
||||
$sourceMap = Read-OrderedJsonMap -Path $sourcePatchPath
|
||||
Assert-PatchCompatibility -SourceMap $sourceMap -TranslatedMap $translatedPatchMap -LanguageCode $languageCode -AllowUnchanged:$AllowUnchangedValues.IsPresent
|
||||
}
|
||||
|
||||
foreach ($entry in $translatedPatchMap.GetEnumerator()) {
|
||||
$key = [string]$entry.Key
|
||||
if (-not $neutralMap.Contains($key)) {
|
||||
throw "Translated patch key '$key' does not exist in the neutral language file."
|
||||
}
|
||||
}
|
||||
|
||||
$outputRoot = Get-FullPath -Path $OutputDir
|
||||
$tmpRoot = Join-Path $outputRoot 'tmp'
|
||||
New-Item -Path $outputRoot -ItemType Directory -Force | Out-Null
|
||||
New-Item -Path $tmpRoot -ItemType Directory -Force | Out-Null
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputJson)) {
|
||||
if (Test-Path -Path $targetPath -PathType Leaf) {
|
||||
$targetName = [System.IO.Path]::GetFileNameWithoutExtension($targetPath)
|
||||
$OutputJson = Join-Path (Split-Path -Path $targetPath -Parent) ('{0}.merged.json' -f $targetName)
|
||||
}
|
||||
else {
|
||||
$OutputJson = $targetPath
|
||||
}
|
||||
}
|
||||
|
||||
$outputJsonPath = Get-FullPath -Path $OutputJson
|
||||
$mergedMap = Merge-LanguageMaps -NeutralMap $neutralMap -TargetMap $targetMap -TranslatedPatchMap $translatedPatchMap
|
||||
Write-OrderedJsonMap -Path $outputJsonPath -Map $mergedMap
|
||||
|
||||
if (-not $KeepIntermediate.IsPresent) {
|
||||
Remove-Item -Path $tmpRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Output "Merged translated patch into: $outputJsonPath"
|
||||
if ($null -ne $sourceMap) {
|
||||
Write-Output "Validated translated patch against: $SourcePatch"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$NeutralJson,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetJson,
|
||||
|
||||
[string]$PatchJson
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
. (Join-Path $PSScriptRoot '..\..\translation-diff-export\scripts\TranslationDiff.JsonTools.ps1')
|
||||
|
||||
$neutralPath = Get-FullPath -Path $NeutralJson
|
||||
$targetPath = Get-FullPath -Path $TargetJson
|
||||
|
||||
if (-not (Test-Path -Path $neutralPath -PathType Leaf)) {
|
||||
throw "Neutral JSON file not found: $neutralPath"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $targetPath -PathType Leaf)) {
|
||||
throw "Target JSON file not found: $targetPath"
|
||||
}
|
||||
|
||||
$neutralMap = Read-OrderedJsonMap -Path $neutralPath
|
||||
$targetMap = Read-OrderedJsonMap -Path $targetPath
|
||||
|
||||
$keysToValidate = New-Object System.Collections.Generic.List[string]
|
||||
if (-not [string]::IsNullOrWhiteSpace($PatchJson)) {
|
||||
$patchPath = Get-FullPath -Path $PatchJson
|
||||
if (-not (Test-Path -Path $patchPath -PathType Leaf)) {
|
||||
throw "Patch JSON file not found: $patchPath"
|
||||
}
|
||||
|
||||
$patchMap = Read-OrderedJsonMap -Path $patchPath
|
||||
foreach ($entry in $patchMap.GetEnumerator()) {
|
||||
$keysToValidate.Add([string]$entry.Key)
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($entry in $targetMap.GetEnumerator()) {
|
||||
$keysToValidate.Add([string]$entry.Key)
|
||||
}
|
||||
}
|
||||
|
||||
$validatedCount = 0
|
||||
foreach ($key in $keysToValidate) {
|
||||
if (-not $neutralMap.Contains($key)) {
|
||||
throw "Target file contains key '$key' that does not exist in the neutral language file."
|
||||
}
|
||||
|
||||
if (-not $targetMap.Contains($key)) {
|
||||
throw "Target file does not contain required key '$key'."
|
||||
}
|
||||
|
||||
$targetValue = [string]$targetMap[$key]
|
||||
Assert-TranslationStructure -SourceValue ([string]$neutralMap[$key]) -TranslatedValue $targetValue -Key $key
|
||||
$validatedCount += 1
|
||||
}
|
||||
|
||||
Write-Output "Validated $validatedCount translated entries against: $neutralPath"
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: translation-diff-translate
|
||||
description: Translates a sparse UniGetUI JSON language patch, writes completed entries into the working copy, preserves placeholders and terminology, and prepares the patch for merge-back. Use when the user asks to translate or localize i18n strings, localization JSON, or language-file patches.
|
||||
---
|
||||
|
||||
# translation diff translate
|
||||
|
||||
Use this skill after `translation-diff-export` produced a `.source.json`, `.translated.json`, and `.reference.json` set and you want to update the sparse translated working copy.
|
||||
|
||||
It translates only the active patch keys, keeps `.translated.json` as the sole writable file, preserves placeholders and formatting, and hands the completed working copy to `translation-diff-import` for merge-back.
|
||||
|
||||
## Execution Expectations
|
||||
|
||||
1. Edit `.translated.json` directly. Do not create helper scripts, temporary reports, or coverage probes.
|
||||
2. Do not install packages or search for external translation services unless the user explicitly asks for automation.
|
||||
3. If `.translated.json` is empty, that is expected. Translate from `.source.json` into `.translated.json`.
|
||||
4. Use `.reference.json` only as terminology guidance, not as a reason to pause and analyze the rest of the repository.
|
||||
5. If the patch is too large to finish reasonably in one pass, report that constraint to the user instead of creating automation on your own.
|
||||
|
||||
## Script
|
||||
|
||||
- `scripts/write-translation-handoff.ps1`: Writes a small `.prompt.md` file that points an agent at this skill with the concrete patch paths created by the export skill.
|
||||
|
||||
## Usage
|
||||
|
||||
Generate a handoff prompt for a translated French working copy:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-diff-translate/scripts/write-translation-handoff.ps1 \
|
||||
-BaseName lang \
|
||||
-Language fr \
|
||||
-SourcePatch ./generated/translation-diff-export/lang.diff.fr.source.json \
|
||||
-TranslatedPatch ./generated/translation-diff-export/lang.diff.fr.translated.json \
|
||||
-ReferencePatch ./generated/translation-diff-export/lang.diff.fr.reference.json \
|
||||
-TargetJson ./src/Languages/lang_fr.json \
|
||||
-NeutralJson ./src/Languages/lang_en.json \
|
||||
-MergedTargetJson ./src/Languages/lang_fr.merged.json \
|
||||
-ValidationScript ./.agents/skills/translation-diff-import/scripts/validate-language-file.ps1 \
|
||||
-OutputPrompt ./generated/translation-diff-export/lang.diff.fr.prompt.md
|
||||
```
|
||||
|
||||
## Translation Rules
|
||||
|
||||
1. Do not edit `.source.json`.
|
||||
2. Write completed translations only into `.translated.json`.
|
||||
3. If a key is not translated yet, omit it from `.translated.json` instead of copying the English source value.
|
||||
4. Preserve key names exactly as-is.
|
||||
5. Preserve placeholders, named tokens, HTML-like fragments, escape sequences, and line breaks.
|
||||
6. Keep translated entries in the same key order as `.source.json` when adding new entries.
|
||||
7. Use `.reference.json` to match existing terminology and tone in the destination language.
|
||||
8. Do not introduce keys that are not present in the source patch.
|
||||
|
||||
## After Translation
|
||||
|
||||
Merge the sparse working copy back into the full destination-language file:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-diff-import/scripts/import-translation-diff.ps1 \
|
||||
-TranslatedPatch ./generated/translation-diff-export/lang.diff.fr.translated.json \
|
||||
-SourcePatch ./generated/translation-diff-export/lang.diff.fr.source.json \
|
||||
-TargetJson ./src/Languages/lang_fr.json \
|
||||
-NeutralJson ./src/Languages/lang_en.json \
|
||||
-OutputJson ./src/Languages/lang_fr.merged.json
|
||||
|
||||
pwsh ./.agents/skills/translation-diff-import/scripts/validate-language-file.ps1 \
|
||||
-NeutralJson ./src/Languages/lang_en.json \
|
||||
-TargetJson ./src/Languages/lang_fr.merged.json \
|
||||
-PatchJson ./generated/translation-diff-export/lang.diff.fr.source.json
|
||||
```
|
||||
@@ -0,0 +1,113 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BaseName,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Language,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SourcePatch,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TranslatedPatch,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReferencePatch,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetJson,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$NeutralJson,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$MergedTargetJson,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ValidationScript,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPrompt,
|
||||
|
||||
[string]$BaseSummary = 'No git baseline was provided. The patch contains untranslated UniGetUI entries only.'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
$promptPath = [System.IO.Path]::GetFullPath($OutputPrompt)
|
||||
$promptDirectory = Split-Path -Path $promptPath -Parent
|
||||
if (-not [string]::IsNullOrWhiteSpace($promptDirectory)) {
|
||||
New-Item -Path $promptDirectory -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
$promptContent = @"
|
||||
# Translate $BaseName to $Language
|
||||
|
||||
## Use Skill
|
||||
|
||||
Use the translation-diff-translate skill for the translation step.
|
||||
|
||||
Detailed workflow and validation rules live in:
|
||||
|
||||
- ./.agents/skills/translation-diff-translate/SKILL.md
|
||||
|
||||
## Context
|
||||
|
||||
- Source patch file: $SourcePatch
|
||||
- Translated working copy: $TranslatedPatch
|
||||
- Reference translations: $ReferencePatch
|
||||
- Full target language file: $TargetJson
|
||||
- Neutral language file: $NeutralJson
|
||||
- $BaseSummary
|
||||
|
||||
## Translation Task
|
||||
|
||||
1. Update only the translated working copy.
|
||||
2. If the translated working copy is empty, translate directly from the source patch into it.
|
||||
3. Keep the translated working copy sparse by omitting keys that are not translated yet.
|
||||
4. Use the reference translations file for terminology, style, and consistency.
|
||||
5. Preserve placeholders, named tokens, HTML-like fragments, escape sequences, and line breaks.
|
||||
6. Do not edit the source patch file.
|
||||
7. Do not write helper scripts, temporary reports, coverage checks, or automation for this task.
|
||||
8. Do not search for external translation tools or install packages unless the user explicitly asks for automation.
|
||||
9. Start translating immediately instead of analyzing repo-wide translation coverage.
|
||||
|
||||
## Expected Action
|
||||
|
||||
- Read the source patch and reference file.
|
||||
- Edit the translated patch file directly.
|
||||
- Add translated JSON entries in source order.
|
||||
- Leave untranslated keys out.
|
||||
- When finished, use the import and validation commands below.
|
||||
|
||||
## Import After Translation
|
||||
|
||||
~~~powershell
|
||||
pwsh ./.agents/skills/translation-diff-import/scripts/import-translation-diff.ps1 -TranslatedPatch "$TranslatedPatch" -SourcePatch "$SourcePatch" -TargetJson "$TargetJson" -NeutralJson "$NeutralJson" -OutputJson "$MergedTargetJson"
|
||||
pwsh "$ValidationScript" -NeutralJson "$NeutralJson" -TargetJson "$MergedTargetJson" -PatchJson "$SourcePatch"
|
||||
~~~
|
||||
|
||||
The import skill validates the translated working copy against the source patch, merges it into the full target file in English key order, and the validation script checks placeholders, HTML-like fragments, and line-break parity.
|
||||
"@
|
||||
|
||||
New-Utf8File -Path $promptPath -Content $promptContent.TrimStart()
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: translation-review
|
||||
description: Reviews UniGetUI .json language files for localization quality, detects parity issues, English-equal entries, wrong-script content, and cross-language outliers, then generates a dataset for LLM-assisted linguistic review. Use when the user asks to review translations, audit localization, inspect i18n or l10n quality, or validate language files.
|
||||
---
|
||||
|
||||
# translation review
|
||||
|
||||
Use this skill when you need a quality review of an existing UniGetUI translation, not just coverage, but correctness, language integrity, and consistency against related languages.
|
||||
|
||||
Typical triggers: review translations, check translation quality, mistranslations, wrong language in translation, untranslated strings, localization QA, translation audit, spot-check a language file.
|
||||
|
||||
## Scope
|
||||
|
||||
- Mechanically detect placeholder, HTML-fragment, and newline mismatches.
|
||||
- Flag entries whose translation still equals the English source in a non-English language file.
|
||||
- Detect wrong-script entries for non-Latin languages such as `ua`, `zh_CN`, `ar`, `ko`, `ja`, and `he`.
|
||||
- Build a cross-language comparison table so the reviewing agent can identify outliers, suspicious wording, and wrong-language content.
|
||||
- The agent performs the final linguistic review using the generated report.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- PowerShell 7 (`pwsh`).
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/review-translation-json.ps1`: Generate the review dataset for one UniGetUI language JSON file.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Generate the review dataset for the target language.
|
||||
2. Confirm the output file exists and contains the expected flagged sections or JSON fields.
|
||||
3. If the script fails or the output is empty, validate the target JSON with `pwsh ./scripts/translation/Verify-Translations.ps1` and rerun the review.
|
||||
4. Read the flagged sections first: parity issues, English-equal entries, and wrong-script entries.
|
||||
5. Spot-check the cross-language comparison table, focusing on labels, buttons, settings text, and error messages.
|
||||
6. Report findings inline with the English source text, observed problem, and suggested correction.
|
||||
|
||||
## Usage
|
||||
|
||||
Generate a Markdown review report for French:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-review/scripts/review-translation-json.ps1 \
|
||||
-TargetJson ./src/Languages/lang_fr.json \
|
||||
-Language fr
|
||||
```
|
||||
|
||||
Generate a report for Ukrainian and include script-detection checks:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-review/scripts/review-translation-json.ps1 \
|
||||
-TargetJson ./src/Languages/lang_ua.json \
|
||||
-Language ua
|
||||
```
|
||||
|
||||
Generate a flagged-only JSON report with no cross-language table:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-review/scripts/review-translation-json.ps1 \
|
||||
-TargetJson ./src/Languages/lang_de.json \
|
||||
-Language de \
|
||||
-OutputFormat Json \
|
||||
-FlaggedOnly
|
||||
```
|
||||
|
||||
Optional parameters:
|
||||
|
||||
- `-NeutralJson` — defaults to `src/Languages/lang_en.json`
|
||||
- `-ComparisonLanguages` — languages to include in the comparison table; defaults to other checked-in `lang_*.json` files
|
||||
- `-OutputPath` — defaults to `generated/translation-review/review.<lang>.md`
|
||||
- `-OutputFormat` — `Markdown` (default) or `Json`
|
||||
- `-FlaggedOnly` — skip the full cross-language table and emit only mechanically flagged entries
|
||||
|
||||
## Output
|
||||
|
||||
The Markdown report is structured as:
|
||||
|
||||
1. Summary
|
||||
2. Parity Issues
|
||||
3. English-Equal Entries
|
||||
4. Wrong Script
|
||||
5. Cross-Language Comparison
|
||||
|
||||
Default output path: `generated/translation-review/review.<lang>.md`
|
||||
|
||||
## Agent Review Guidelines
|
||||
|
||||
### Flagged sections
|
||||
|
||||
- Parity issues: confirm whether the placeholder, tag, or newline mismatch is a genuine error or an acceptable localized variant.
|
||||
- English-equal entries: decide whether the English value is an acceptable technical or brand string, or whether it should be translated.
|
||||
- Wrong script: almost always an error for non-Latin languages. Verify against comparison languages and flag for re-translation when confirmed.
|
||||
|
||||
### Cross-language comparison table
|
||||
|
||||
- Sample at least 20–30 rows and prefer entries that look like UI labels, buttons, dialogs, settings descriptions, or error messages.
|
||||
- Look for wrong language, suspiciously different meaning, missing words, or copied English that should probably be translated.
|
||||
- Ignore obvious brand names and technical identifiers.
|
||||
|
||||
### Output format for findings
|
||||
|
||||
Report each issue in this format:
|
||||
|
||||
```text
|
||||
**Source**: `Some English source text`
|
||||
**Problem**: [describe the issue]
|
||||
**Current value**: [the problematic translation]
|
||||
**Suggested correction**: [corrected value, if confident]
|
||||
**Confidence**: High / Medium / Low
|
||||
```
|
||||
|
||||
Group findings by type: parity, English-equal, wrong language, wrong script, or other.
|
||||
|
||||
## Integration with Other Skills
|
||||
|
||||
- Use [translation-status](../translation-status/SKILL.md) first if you need a coverage snapshot before reviewing quality.
|
||||
- Use [translation-diff-export](../translation-diff-export/SKILL.md) to produce a sparse patch for entries that need re-translation after review.
|
||||
- Use [translation-diff-import](../translation-diff-import/SKILL.md) to merge corrected entries back into the full language file.
|
||||
@@ -0,0 +1,435 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$TargetJson,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Language,
|
||||
|
||||
[string]$NeutralJson,
|
||||
[string]$OutputPath,
|
||||
[string[]]$ComparisonLanguages,
|
||||
|
||||
[ValidateSet('Markdown', 'Json')]
|
||||
[string]$OutputFormat = 'Markdown',
|
||||
|
||||
[switch]$FlaggedOnly
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..\..'))
|
||||
. (Join-Path $repoRoot 'scripts\translation\Languages\TranslationJsonTools.ps1')
|
||||
|
||||
function Get-TranslationReviewLanguageReferencePath {
|
||||
return Join-Path $repoRoot 'src\Languages\Data\LanguagesReference.json'
|
||||
}
|
||||
|
||||
function Get-TranslationReviewLanguagesDirectory {
|
||||
return Join-Path $repoRoot 'src\Languages'
|
||||
}
|
||||
|
||||
function Get-TranslationReviewLanguageReference {
|
||||
return Read-OrderedJsonMap -Path (Get-TranslationReviewLanguageReferencePath)
|
||||
}
|
||||
|
||||
function Get-TranslationReviewDisplayLanguage {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LanguageCode,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IDictionary]$LanguageReference
|
||||
)
|
||||
|
||||
if ($LanguageReference.Contains($LanguageCode)) {
|
||||
return [string]$LanguageReference[$LanguageCode]
|
||||
}
|
||||
|
||||
return $LanguageCode
|
||||
}
|
||||
|
||||
function Test-TranslationReviewTechnicalToken {
|
||||
param(
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
|
||||
if ($Value -match '^\d[\d\s%:./-]*$') { return $true }
|
||||
if ($Value -match '^[A-Z0-9]{1,12}$') { return $true }
|
||||
if ($Value -match '^https?://') { return $true }
|
||||
if ($Value -match '^[A-Z][a-zA-Z0-9]+$' -and $Value.Length -le 30) { return $true }
|
||||
if ($Value.Length -le 2) { return $true }
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-TranslationReviewScriptInfo {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LanguageCode
|
||||
)
|
||||
|
||||
switch ($LanguageCode) {
|
||||
'ar' { return @{ Name = 'Arabic'; Pattern = '[\u0600-\u06ff]' } }
|
||||
'be' { return @{ Name = 'Cyrillic'; Pattern = '[\u0400-\u04ff]' } }
|
||||
'bg' { return @{ Name = 'Cyrillic'; Pattern = '[\u0400-\u04ff]' } }
|
||||
'bn' { return @{ Name = 'Bengali'; Pattern = '[\u0980-\u09ff]' } }
|
||||
'el' { return @{ Name = 'Greek'; Pattern = '[\u0370-\u03ff]' } }
|
||||
'fa' { return @{ Name = 'Arabic'; Pattern = '[\u0600-\u06ff]' } }
|
||||
'he' { return @{ Name = 'Hebrew'; Pattern = '[\u0590-\u05ff]' } }
|
||||
'hi' { return @{ Name = 'Devanagari'; Pattern = '[\u0900-\u097f]' } }
|
||||
'ja' { return @{ Name = 'Japanese'; Pattern = '[\u3040-\u30ff\u4e00-\u9fff]' } }
|
||||
'ka' { return @{ Name = 'Georgian'; Pattern = '[\u10a0-\u10ff]' } }
|
||||
'kn' { return @{ Name = 'Kannada'; Pattern = '[\u0c80-\u0cff]' } }
|
||||
'ko' { return @{ Name = 'Korean'; Pattern = '[\uac00-\ud7a3]' } }
|
||||
'mk' { return @{ Name = 'Cyrillic'; Pattern = '[\u0400-\u04ff]' } }
|
||||
'mr' { return @{ Name = 'Devanagari'; Pattern = '[\u0900-\u097f]' } }
|
||||
'ru' { return @{ Name = 'Cyrillic'; Pattern = '[\u0400-\u04ff]' } }
|
||||
'sa' { return @{ Name = 'Devanagari'; Pattern = '[\u0900-\u097f]' } }
|
||||
'si' { return @{ Name = 'Sinhala'; Pattern = '[\u0d80-\u0dff]' } }
|
||||
'ta' { return @{ Name = 'Tamil'; Pattern = '[\u0b80-\u0bff]' } }
|
||||
'th' { return @{ Name = 'Thai'; Pattern = '[\u0e00-\u0e7f]' } }
|
||||
'ua' { return @{ Name = 'Cyrillic'; Pattern = '[\u0400-\u04ff]' } }
|
||||
'ur' { return @{ Name = 'Arabic'; Pattern = '[\u0600-\u06ff]' } }
|
||||
'zh_CN' { return @{ Name = 'CJK'; Pattern = '[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]' } }
|
||||
'zh_TW' { return @{ Name = 'CJK'; Pattern = '[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]' } }
|
||||
default { return $null }
|
||||
}
|
||||
}
|
||||
|
||||
function Test-TranslationReviewExpectedScript {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LanguageCode,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
$scriptInfo = Get-TranslationReviewScriptInfo -LanguageCode $LanguageCode
|
||||
if ($null -eq $scriptInfo) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
ExpectedScript = $scriptInfo.Name
|
||||
HasExpectedScript = [bool]($Value -match $scriptInfo.Pattern)
|
||||
}
|
||||
}
|
||||
|
||||
function Get-TranslationReviewParityIssues {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SourceValue,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TranslatedValue
|
||||
)
|
||||
|
||||
$issues = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
$sourcePlaceholderSignature = (Get-PlaceholderTokens -Value $SourceValue) -join "`n"
|
||||
$translatedPlaceholderSignature = (Get-PlaceholderTokens -Value $TranslatedValue) -join "`n"
|
||||
if ($sourcePlaceholderSignature -ne $translatedPlaceholderSignature) {
|
||||
$issues.Add('Placeholder mismatch')
|
||||
}
|
||||
|
||||
$sourceHtmlSignature = (Get-HtmlTokens -Value $SourceValue) -join "`n"
|
||||
$translatedHtmlSignature = (Get-HtmlTokens -Value $TranslatedValue) -join "`n"
|
||||
if ($sourceHtmlSignature -ne $translatedHtmlSignature) {
|
||||
$issues.Add('HTML fragment mismatch')
|
||||
}
|
||||
|
||||
if ((Get-NewlineCount -Value $SourceValue) -ne (Get-NewlineCount -Value $TranslatedValue)) {
|
||||
$issues.Add('Line-break mismatch')
|
||||
}
|
||||
|
||||
return @($issues)
|
||||
}
|
||||
|
||||
function Format-TranslationReviewMarkdownCell {
|
||||
param(
|
||||
[AllowNull()]
|
||||
[string]$Value
|
||||
)
|
||||
|
||||
if ($null -eq $Value) {
|
||||
return '_-_'
|
||||
}
|
||||
|
||||
$escapedValue = $Value -replace '\|', '\|'
|
||||
if ($escapedValue.Length -gt 120) {
|
||||
$escapedValue = $escapedValue.Substring(0, 117) + '...'
|
||||
}
|
||||
|
||||
return $escapedValue
|
||||
}
|
||||
|
||||
if (-not $NeutralJson) {
|
||||
$NeutralJson = Join-Path (Get-TranslationReviewLanguagesDirectory) 'lang_en.json'
|
||||
}
|
||||
|
||||
$languageReference = Get-TranslationReviewLanguageReference
|
||||
if (-not $languageReference.Contains($Language)) {
|
||||
throw "Unknown language code '$Language'."
|
||||
}
|
||||
|
||||
if (-not $ComparisonLanguages) {
|
||||
$ComparisonLanguages = @()
|
||||
foreach ($entry in $languageReference.GetEnumerator()) {
|
||||
$code = [string]$entry.Key
|
||||
if ($code -in @('default', 'en', $Language)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$comparisonPath = Join-Path (Get-TranslationReviewLanguagesDirectory) ("lang_{0}.json" -f $code)
|
||||
if (Test-Path -Path $comparisonPath -PathType Leaf) {
|
||||
$ComparisonLanguages += $code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $OutputPath) {
|
||||
$extension = if ($OutputFormat -eq 'Json') { 'json' } else { 'md' }
|
||||
$OutputPath = Join-Path $repoRoot ("generated\translation-review\review.{0}.{1}" -f $Language, $extension)
|
||||
}
|
||||
|
||||
$neutralMap = Read-OrderedJsonMap -Path $NeutralJson
|
||||
$targetMap = Read-OrderedJsonMap -Path $TargetJson
|
||||
|
||||
$comparisonMaps = [ordered]@{}
|
||||
foreach ($code in $ComparisonLanguages) {
|
||||
$comparisonPath = Join-Path (Get-TranslationReviewLanguagesDirectory) ("lang_{0}.json" -f $code)
|
||||
if (Test-Path -Path $comparisonPath -PathType Leaf) {
|
||||
$comparisonMaps[$code] = Read-OrderedJsonMap -Path $comparisonPath
|
||||
}
|
||||
}
|
||||
|
||||
$parityIssues = [System.Collections.Generic.List[object]]::new()
|
||||
$englishEqualEntries = [System.Collections.Generic.List[object]]::new()
|
||||
$wrongScriptEntries = [System.Collections.Generic.List[object]]::new()
|
||||
$crossLanguageEntries = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
foreach ($entry in $neutralMap.GetEnumerator()) {
|
||||
$sourceText = [string]$entry.Key
|
||||
$englishValue = [string]$entry.Value
|
||||
|
||||
if (-not $targetMap.Contains($sourceText)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$targetValue = [string]$targetMap[$sourceText]
|
||||
if ([string]::IsNullOrWhiteSpace($targetValue)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$crossLanguage = [ordered]@{}
|
||||
foreach ($code in $comparisonMaps.Keys) {
|
||||
$crossLanguage[$code] = if ($comparisonMaps[$code].Contains($sourceText)) {
|
||||
[string]$comparisonMaps[$code][$sourceText]
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
}
|
||||
|
||||
if ($Language -ne 'en' -and $targetValue -ceq $englishValue -and -not (Test-TranslationReviewTechnicalToken -Value $englishValue)) {
|
||||
$englishEqualEntries.Add([pscustomobject]@{
|
||||
SourceText = $sourceText
|
||||
English = $englishValue
|
||||
Translation = $targetValue
|
||||
CrossLanguage = $crossLanguage
|
||||
})
|
||||
}
|
||||
else {
|
||||
$issues = @(Get-TranslationReviewParityIssues -SourceValue $englishValue -TranslatedValue $targetValue)
|
||||
if ($issues.Count -gt 0) {
|
||||
$parityIssues.Add([pscustomobject]@{
|
||||
SourceText = $sourceText
|
||||
English = $englishValue
|
||||
Translation = $targetValue
|
||||
Issues = $issues
|
||||
CrossLanguage = $crossLanguage
|
||||
})
|
||||
}
|
||||
|
||||
$scriptCheck = Test-TranslationReviewExpectedScript -LanguageCode $Language -Value $targetValue
|
||||
if ($null -ne $scriptCheck -and -not $scriptCheck.HasExpectedScript -and -not (Test-TranslationReviewTechnicalToken -Value $englishValue)) {
|
||||
$wrongScriptEntries.Add([pscustomobject]@{
|
||||
SourceText = $sourceText
|
||||
English = $englishValue
|
||||
Translation = $targetValue
|
||||
ExpectedScript = $scriptCheck.ExpectedScript
|
||||
CrossLanguage = $crossLanguage
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$crossLanguageEntries.Add([pscustomobject]@{
|
||||
SourceText = $sourceText
|
||||
English = $englishValue
|
||||
Translation = $targetValue
|
||||
CrossLanguage = $crossLanguage
|
||||
})
|
||||
}
|
||||
|
||||
$displayName = Get-TranslationReviewDisplayLanguage -LanguageCode $Language -LanguageReference $languageReference
|
||||
|
||||
$output = if ($OutputFormat -eq 'Json') {
|
||||
[pscustomobject]@{
|
||||
Language = $Language
|
||||
DisplayName = $displayName
|
||||
GeneratedAt = (Get-Date -Format 'o')
|
||||
TargetJson = $TargetJson
|
||||
TotalReviewed = $crossLanguageEntries.Count
|
||||
ParityIssueCount = $parityIssues.Count
|
||||
EnglishEqualCount = $englishEqualEntries.Count
|
||||
WrongScriptCount = $wrongScriptEntries.Count
|
||||
ParityIssues = $parityIssues.ToArray()
|
||||
EnglishEqual = $englishEqualEntries.ToArray()
|
||||
WrongScript = $wrongScriptEntries.ToArray()
|
||||
CrossLanguage = if (-not $FlaggedOnly) { $crossLanguageEntries.ToArray() } else { @() }
|
||||
} | ConvertTo-Json -Depth 10
|
||||
}
|
||||
else {
|
||||
$builder = [System.Text.StringBuilder]::new()
|
||||
[void]$builder.AppendLine("# Translation Review: $displayName ($Language)")
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine("**File**: ``$TargetJson``")
|
||||
[void]$builder.AppendLine("**Generated**: $(Get-Date -Format 'yyyy-MM-dd HH:mm')")
|
||||
[void]$builder.AppendLine("**Comparison languages**: $($comparisonMaps.Keys -join ', ')")
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('## Summary')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('| Category | Count |')
|
||||
[void]$builder.AppendLine('|---|---|')
|
||||
[void]$builder.AppendLine("| Reviewed translated entries | $($crossLanguageEntries.Count) |")
|
||||
[void]$builder.AppendLine("| Parity issues | $($parityIssues.Count) |")
|
||||
[void]$builder.AppendLine("| English-equal entries | $($englishEqualEntries.Count) |")
|
||||
[void]$builder.AppendLine("| Wrong script entries | $($wrongScriptEntries.Count) |")
|
||||
[void]$builder.AppendLine()
|
||||
|
||||
[void]$builder.AppendLine('## Parity Issues')
|
||||
[void]$builder.AppendLine()
|
||||
if ($parityIssues.Count -eq 0) {
|
||||
[void]$builder.AppendLine('_No parity issues found._')
|
||||
}
|
||||
else {
|
||||
foreach ($issue in $parityIssues) {
|
||||
[void]$builder.AppendLine("### ``$($issue.SourceText)``")
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine("- **English**: $($issue.English)")
|
||||
[void]$builder.AppendLine("- **$Language**: $($issue.Translation)")
|
||||
foreach ($problem in $issue.Issues) {
|
||||
[void]$builder.AppendLine("- **Issue**: $problem")
|
||||
}
|
||||
foreach ($code in $issue.CrossLanguage.Keys) {
|
||||
$value = $issue.CrossLanguage[$code]
|
||||
if (-not [string]::IsNullOrWhiteSpace($value) -and $value -ne $issue.English) {
|
||||
[void]$builder.AppendLine("- **$code**: $value")
|
||||
}
|
||||
}
|
||||
[void]$builder.AppendLine()
|
||||
}
|
||||
}
|
||||
[void]$builder.AppendLine()
|
||||
|
||||
[void]$builder.AppendLine('## English-Equal Entries')
|
||||
[void]$builder.AppendLine()
|
||||
if ($englishEqualEntries.Count -eq 0) {
|
||||
[void]$builder.AppendLine('_No suspicious English-equal entries found._')
|
||||
}
|
||||
else {
|
||||
foreach ($item in $englishEqualEntries) {
|
||||
[void]$builder.AppendLine("### ``$($item.SourceText)``")
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine("- **English**: $($item.English)")
|
||||
[void]$builder.AppendLine("- **$Language**: $($item.Translation)")
|
||||
foreach ($code in $item.CrossLanguage.Keys) {
|
||||
$value = $item.CrossLanguage[$code]
|
||||
if (-not [string]::IsNullOrWhiteSpace($value) -and $value -ne $item.English) {
|
||||
[void]$builder.AppendLine("- **$code**: $value")
|
||||
}
|
||||
}
|
||||
[void]$builder.AppendLine()
|
||||
}
|
||||
}
|
||||
[void]$builder.AppendLine()
|
||||
|
||||
[void]$builder.AppendLine('## Wrong Script')
|
||||
[void]$builder.AppendLine()
|
||||
if ($wrongScriptEntries.Count -eq 0) {
|
||||
$scriptInfo = Get-TranslationReviewScriptInfo -LanguageCode $Language
|
||||
if ($null -eq $scriptInfo) {
|
||||
[void]$builder.AppendLine('_Not applicable for Latin-script languages._')
|
||||
}
|
||||
else {
|
||||
[void]$builder.AppendLine('_No wrong-script entries found._')
|
||||
}
|
||||
}
|
||||
else {
|
||||
foreach ($item in $wrongScriptEntries) {
|
||||
[void]$builder.AppendLine("### ``$($item.SourceText)``")
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine("- **English**: $($item.English)")
|
||||
[void]$builder.AppendLine("- **$Language**: $($item.Translation)")
|
||||
[void]$builder.AppendLine("- **Expected script**: $($item.ExpectedScript)")
|
||||
foreach ($code in $item.CrossLanguage.Keys) {
|
||||
$value = $item.CrossLanguage[$code]
|
||||
if (-not [string]::IsNullOrWhiteSpace($value) -and $value -ne $item.English) {
|
||||
[void]$builder.AppendLine("- **$code**: $value")
|
||||
}
|
||||
}
|
||||
[void]$builder.AppendLine()
|
||||
}
|
||||
}
|
||||
[void]$builder.AppendLine()
|
||||
|
||||
if (-not $FlaggedOnly) {
|
||||
$comparisonCodes = @($comparisonMaps.Keys)
|
||||
[void]$builder.AppendLine('## Cross-Language Comparison')
|
||||
[void]$builder.AppendLine()
|
||||
[void]$builder.AppendLine('Use this table for spot-checking suspicious wording, wrong language, and unusual differences against related languages.')
|
||||
[void]$builder.AppendLine()
|
||||
$header = '| Source | English | ' + $Language + ' | ' + ($comparisonCodes -join ' | ') + ' |'
|
||||
$divider = '|---|---|' + ('---|' * ($comparisonCodes.Count + 1))
|
||||
[void]$builder.AppendLine($header)
|
||||
[void]$builder.AppendLine($divider)
|
||||
foreach ($row in $crossLanguageEntries) {
|
||||
$cells = New-Object System.Collections.Generic.List[string]
|
||||
$cells.Add((Format-TranslationReviewMarkdownCell -Value $row.SourceText))
|
||||
$cells.Add((Format-TranslationReviewMarkdownCell -Value $row.English))
|
||||
$cells.Add((Format-TranslationReviewMarkdownCell -Value $row.Translation))
|
||||
foreach ($code in $comparisonCodes) {
|
||||
$cells.Add((Format-TranslationReviewMarkdownCell -Value $row.CrossLanguage[$code]))
|
||||
}
|
||||
|
||||
[void]$builder.AppendLine('| ' + ($cells -join ' | ') + ' |')
|
||||
}
|
||||
}
|
||||
|
||||
$builder.ToString()
|
||||
}
|
||||
|
||||
$outputDirectory = Split-Path -Path $OutputPath -Parent
|
||||
if (-not (Test-Path -Path $outputDirectory -PathType Container)) {
|
||||
New-Item -Path $outputDirectory -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
[System.IO.File]::WriteAllText($OutputPath, [string]$output, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
Write-Host "Review written to: $OutputPath"
|
||||
Write-Host " Parity issues: $($parityIssues.Count)"
|
||||
Write-Host " English-equal: $($englishEqualEntries.Count)"
|
||||
Write-Host " Wrong script: $($wrongScriptEntries.Count)"
|
||||
Write-Host " Entries reviewed: $($crossLanguageEntries.Count)"
|
||||
|
||||
[pscustomobject]@{
|
||||
OutputPath = $OutputPath
|
||||
ParityIssueCount = $parityIssues.Count
|
||||
EnglishEqualCount = $englishEqualEntries.Count
|
||||
WrongScriptCount = $wrongScriptEntries.Count
|
||||
ReviewedEntryCount = $crossLanguageEntries.Count
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: translation-source-sync
|
||||
description: Synchronizes the UniGetUI English language file with source-code usage, identifies missing translation keys, removes unused entries, reports localization drift, and can reorder locale files to match the English key ordering. Use when the user asks to sync language files, missing translations, i18n drift, or align locale file ordering with English.
|
||||
---
|
||||
|
||||
# translation source sync
|
||||
|
||||
Use this skill when UniGetUI source code changed and you need to keep [src/Languages/lang_en.json](src/Languages/lang_en.json) aligned with the strings actually used by the application, or when you need downstream locale files reordered to match the English key ordering.
|
||||
|
||||
It scans supported C#, WinUI XAML, and Avalonia AXAML patterns, finds missing English keys, removes unused entries, reports translation-source warnings that still need manual cleanup, and can align locale file ordering to the English layout.
|
||||
|
||||
## Scope
|
||||
|
||||
- Extract literal translation keys from supported UniGetUI source patterns.
|
||||
- Add new English keys missing from `lang_en.json`.
|
||||
- Remove English keys that are no longer used.
|
||||
- Reorder non-English locale files to match the English key ordering.
|
||||
- Warn about interpolated `CoreTools.Translate($"...")` calls that are not synchronized automatically.
|
||||
- Leave downstream language propagation to the existing translation diff workflow.
|
||||
|
||||
## Supported extraction sources
|
||||
|
||||
- `CoreTools.Translate("...")` in C#.
|
||||
- `CoreTools.AutoTranslated("...")` in C#.
|
||||
- `widgets:TranslatedTextBlock Text="..."` in WinUI XAML.
|
||||
- `settings:TranslatedTextBlock Text="..."` in Avalonia AXAML.
|
||||
- `{t:Translate Some text}` and `{t:Translate Text='A, B, C'}` in Avalonia AXAML.
|
||||
|
||||
## Out of scope for v1
|
||||
|
||||
- Interpolated translation calls such as `CoreTools.Translate($"Running {value}")`.
|
||||
- Non-literal translation inputs passed through variables.
|
||||
- CI enforcement.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/sync-translation-sources.ps1`: Skill wrapper around the repository sync script.
|
||||
- `scripts/set-translation-boundary-order.ps1`: Skill wrapper around the repository locale reordering script.
|
||||
- `scripts/test-translation-source-sync.ps1`: Skill wrapper around the repository smoke test.
|
||||
- `../../../../scripts/translation/Sync-TranslationSources.ps1`: Canonical repository implementation used by the wrapper.
|
||||
- `../../../../scripts/translation/Set-TranslationBoundaryOrder.ps1`: Canonical repository implementation used by the wrapper.
|
||||
- `../../../../scripts/translation/Test-TranslationSourceSync.ps1`: Canonical smoke test used by the wrapper.
|
||||
|
||||
## Usage
|
||||
|
||||
Synchronize the checked-in English language file in place:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-source-sync/scripts/sync-translation-sources.ps1
|
||||
```
|
||||
|
||||
Check whether the English file is out of sync without writing changes:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-source-sync/scripts/sync-translation-sources.ps1 -CheckOnly
|
||||
```
|
||||
|
||||
Reorder locale files to match the English key ordering:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-source-sync/scripts/set-translation-boundary-order.ps1
|
||||
```
|
||||
|
||||
Preview whether locale files need reordering without writing changes:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-source-sync/scripts/set-translation-boundary-order.ps1 -CheckOnly
|
||||
```
|
||||
|
||||
Run the smoke test:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-source-sync/scripts/test-translation-source-sync.ps1
|
||||
```
|
||||
|
||||
## Recommended workflow
|
||||
|
||||
1. Run translation source sync after changing any translatable source string.
|
||||
2. If `-CheckOnly` reports drift, run the full sync to rewrite `lang_en.json`.
|
||||
3. Review warnings for interpolated translation calls and convert them to stable literals or add the missing English keys manually when needed.
|
||||
4. Run `pwsh ./.agents/skills/translation-source-sync/scripts/set-translation-boundary-order.ps1` to align locale ordering with English.
|
||||
5. Run `pwsh ./scripts/translation/Verify-Translations.ps1` to confirm the language files still validate cleanly.
|
||||
6. Run `pwsh ./.agents/skills/translation-source-sync/scripts/test-translation-source-sync.ps1` if you changed the sync workflow itself.
|
||||
7. Export changed work for translators with [translation-diff-export](../translation-diff-export/SKILL.md).
|
||||
|
||||
## Notes
|
||||
|
||||
- Existing English values are preserved for retained keys; newly added English entries default to `key == value`.
|
||||
- The sync script preserves current key order for retained entries and appends newly discovered keys deterministically.
|
||||
- The locale reorder script follows English as the canonical order and appends unmapped locale-only keys at the end.
|
||||
- The smoke test uses a temporary synthetic repo so it does not mutate the checked-in language files.
|
||||
@@ -0,0 +1,26 @@
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[string]$RepositoryRoot,
|
||||
[string]$EnglishFilePath,
|
||||
[string]$LanguagesDirectory,
|
||||
[string]$InventoryOutputPath,
|
||||
[switch]$IncludeEnglish,
|
||||
[switch]$CheckOnly
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..\..'))
|
||||
$scriptPath = Join-Path $repoRoot 'scripts\translation\Set-TranslationBoundaryOrder.ps1'
|
||||
if (-not (Test-Path -Path $scriptPath -PathType Leaf)) {
|
||||
throw "Translation boundary reorder script not found: $scriptPath"
|
||||
}
|
||||
|
||||
& $scriptPath `
|
||||
-RepositoryRoot $RepositoryRoot `
|
||||
-EnglishFilePath $EnglishFilePath `
|
||||
-LanguagesDirectory $LanguagesDirectory `
|
||||
-InventoryOutputPath $InventoryOutputPath `
|
||||
-IncludeEnglish:$IncludeEnglish.IsPresent `
|
||||
-CheckOnly:$CheckOnly.IsPresent
|
||||
@@ -0,0 +1,17 @@
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[string]$RepositoryRoot,
|
||||
[string]$EnglishFilePath,
|
||||
[switch]$CheckOnly
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..\..'))
|
||||
$scriptPath = Join-Path $repoRoot 'scripts\translation\Sync-TranslationSources.ps1'
|
||||
if (-not (Test-Path -Path $scriptPath -PathType Leaf)) {
|
||||
throw "Translation source sync script not found: $scriptPath"
|
||||
}
|
||||
|
||||
& $scriptPath -RepositoryRoot $RepositoryRoot -EnglishFilePath $EnglishFilePath -CheckOnly:$CheckOnly.IsPresent
|
||||
@@ -0,0 +1,10 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..\..'))
|
||||
$scriptPath = Join-Path $repoRoot 'scripts\translation\Test-TranslationSourceSync.ps1'
|
||||
if (-not (Test-Path -Path $scriptPath -PathType Leaf)) {
|
||||
throw "Translation source sync smoke test script not found: $scriptPath"
|
||||
}
|
||||
|
||||
& $scriptPath
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: translation-status
|
||||
description: Analyzes checked-in UniGetUI language files to calculate translation percentages, surface untranslated coverage data, and generate localization status reports.
|
||||
---
|
||||
|
||||
# translation status
|
||||
|
||||
Use this skill when the user asks for UniGetUI translation progress, localization or i18n coverage, untranslated-language reports, or a language support status summary.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- PowerShell 7 (`pwsh`).
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/get-translation-status.ps1`: Skill wrapper you should invoke from the skill.
|
||||
- `../../../../scripts/translation/Get-TranslationStatus.ps1`: Canonical repository implementation delegated to by the wrapper.
|
||||
|
||||
## Usage
|
||||
|
||||
Show the default table summary:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-status/scripts/get-translation-status.ps1
|
||||
```
|
||||
|
||||
Show only incomplete languages as markdown:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-status/scripts/get-translation-status.ps1 \
|
||||
-OutputFormat Markdown \
|
||||
-OnlyIncomplete
|
||||
```
|
||||
|
||||
Write JSON output to a file:
|
||||
|
||||
```powershell
|
||||
pwsh ./.agents/skills/translation-status/scripts/get-translation-status.ps1 \
|
||||
-OutputFormat Json \
|
||||
-OutputPath ./generated/translation-status.json
|
||||
```
|
||||
|
||||
## Output Fields
|
||||
|
||||
- `Code`: language code
|
||||
- `Language`: display name
|
||||
- `Completion`: computed completion percentage using English active keys only
|
||||
- `Translated`, `Missing`, `Empty`, `SourceEqual`, `Extra`: per-language entry counts
|
||||
- `Stored` and `Delta`: stored percentage metadata and difference from the computed result
|
||||
|
||||
## Notes
|
||||
|
||||
- The script treats source-equal values as untranslated for non-English languages.
|
||||
- Completion never reports `100%` unless every active English key is present and translated.
|
||||
- `Extra` counts locale-only keys not present in the English file.
|
||||
- Use `-IncludeEnglish` if you want the `en` row included in the report.
|
||||
- Use `-OnlyIncomplete` to focus on languages that still need work.
|
||||
- For the full parameter surface, inspect `../../../../scripts/translation/Get-TranslationStatus.ps1`.
|
||||
@@ -0,0 +1,22 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Table', 'Json', 'Markdown')]
|
||||
[string]$OutputFormat = 'Table',
|
||||
|
||||
[switch]$IncludeEnglish,
|
||||
|
||||
[switch]$OnlyIncomplete,
|
||||
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..\..'))
|
||||
$scriptPath = Join-Path $repoRoot 'scripts\translation\Get-TranslationStatus.ps1'
|
||||
if (-not (Test-Path -Path $scriptPath -PathType Leaf)) {
|
||||
throw "Translation status script not found: $scriptPath"
|
||||
}
|
||||
|
||||
& $scriptPath -OutputFormat $OutputFormat -IncludeEnglish:$IncludeEnglish.IsPresent -OnlyIncomplete:$OnlyIncomplete.IsPresent -OutputPath $OutputPath
|
||||
Reference in New Issue
Block a user