diff --git a/.gitignore b/.gitignore index c534fa2..db513d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ build/ build-stage* .tools/ +.cache/ *.pak *.tmp *.temp diff --git a/ACTIONS.md b/ACTIONS.md index e4ac5d4..22fb15a 100644 --- a/ACTIONS.md +++ b/ACTIONS.md @@ -28,6 +28,45 @@ REPORT_FORMAT: - remaining ACTIONS: + translation:diff: + intent: fetch_upstream_english_and_compare_with_ru + inputs: + - AGENTS.md::Canonical Paths::Upstream English reference + - Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml + plan: + - download_upstream_english_into_ignored_cache + - compare_en_vs_ru_by_contentuid_and_version + - classify_diff_into_missing_changed_stale + - write_machine_readable_and_markdown_reports_for_local_review + checks: + - xml_valid + - cache_path_gitignored + - local_only_no_ci_workflow_required + outputs: + - .cache/upstream/english.xml + - build/translation-diff/summary.json + - build/translation-diff/summary.md + - build/translation-diff/candidates.json + translation:apply: + intent: apply_translation_edits_to_russian_xml + inputs: + - Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml + - build/translation-diff/candidates.json + - external_edit_file_with_updates + plan: + - create_temporary_copy_of_russian_xml + - load_edit_file_and_temporary_xml + - apply_updates_and_optional_new_entries_by_contentuid + - write_utf8_bom_xml_to_temporary_copy + - validate_temporary_xml_via_separate_script + - replace_original_russian_xml_after_successful_validation + - report_changed_entries + checks: + - xml_valid + - contentuid_uniqueness_preserved + - only_requested_entries_changed + outputs: + - Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml translation:update: intent: sync_ru_translation_with_upstream inputs: @@ -35,16 +74,18 @@ ACTIONS: - glossary/glossary.normalized.json - AGENTS.md::Canonical Paths::Upstream English reference plan: - - compare_en_vs_ru_by_keys - - classify_diff_into_new_changed_stale_candidate - - update_ru_for_new_and_changed_using_glossary - - validate_xml_structure_and_service_attributes - - prepare_delta_summary_counts + - refresh_upstream_english_cache + - compare_en_vs_ru_by_contentuid_and_version + - if_no_diff_report_translation_is_up_to_date_and_stop + - else_apply_prepared_edits_to_temporary_russian_copy + - validate_temporary_xml_via_separate_script + - replace_original_russian_xml_after_successful_validation checks: - xml_valid - glossary_consistency - scope_limited_to_localization_and_allowed_metadata outputs: + - message: translation_up_to_date - Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml - optional: Mods/DnD 5.5e AIO Russian/meta.lsx (release-only) after_success: diff --git a/scripts/apply-translation-edits.ps1 b/scripts/apply-translation-edits.ps1 new file mode 100644 index 0000000..9344c27 --- /dev/null +++ b/scripts/apply-translation-edits.ps1 @@ -0,0 +1,186 @@ +param( + [string]$RussianPath = "Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml", + [Parameter(Mandatory = $true)] + [string]$EditsPath +) + +$ErrorActionPreference = "Stop" + +function Read-XmlDocument { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + throw "XML file was not found: '$resolvedPath'." + } + + [xml]$xml = Get-Content -LiteralPath $resolvedPath -Raw + if ($null -eq $xml.SelectSingleNode('/contentList')) { + throw "XML file does not contain '/contentList': '$resolvedPath'." + } + + return @{ + Path = $resolvedPath + Xml = $xml + } +} + +function Get-ContentNodeMap { + param( + [Parameter(Mandatory = $true)] + [xml]$Xml + ) + + $map = @{} + foreach ($node in $Xml.SelectNodes('/contentList/content')) { + $contentUid = [string]$node.GetAttribute("contentuid") + if ([string]::IsNullOrWhiteSpace($contentUid)) { + continue + } + + if ($map.ContainsKey($contentUid)) { + throw "Duplicate contentuid found in target XML: '$contentUid'." + } + + $map[$contentUid] = $node + } + + return $map +} + +$russianDocument = Read-XmlDocument -Path $RussianPath +$temporaryRussianPath = "$($russianDocument.Path).tmp" +$validateScriptPath = Join-Path $PSScriptRoot "validate-translation-xml.ps1" + +if (-not (Test-Path -LiteralPath $validateScriptPath)) { + throw "Validation script was not found: '$validateScriptPath'." +} + +if (Test-Path -LiteralPath $temporaryRussianPath) { + Remove-Item -LiteralPath $temporaryRussianPath -Force +} + +Copy-Item -LiteralPath $russianDocument.Path -Destination $temporaryRussianPath -Force +$russianDocument = Read-XmlDocument -Path $temporaryRussianPath +$resolvedEditsPath = [System.IO.Path]::GetFullPath($EditsPath) +if (-not (Test-Path -LiteralPath $resolvedEditsPath)) { + throw "Edits file was not found: '$resolvedEditsPath'." +} + +$edits = Get-Content -LiteralPath $resolvedEditsPath -Raw | ConvertFrom-Json -Depth 10 +if ($null -eq $edits) { + throw "Edits file is empty or invalid JSON: '$resolvedEditsPath'." +} + +$contentListNode = $russianDocument.Xml.SelectSingleNode('/contentList') +if ($null -eq $contentListNode) { + throw "Target russian.xml does not contain '/contentList': '$($russianDocument.Path)'." +} + +$nodeMap = Get-ContentNodeMap -Xml $russianDocument.Xml +$updatedEntries = New-Object System.Collections.Generic.List[string] +$addedEntries = New-Object System.Collections.Generic.List[string] + +$updates = @() +if ($edits.PSObject.Properties.Name -contains "updates" -and $null -ne $edits.updates) { + $updates = @($edits.updates) +} + +foreach ($edit in $updates) { + $contentUid = [string]$edit.contentuid + if ([string]::IsNullOrWhiteSpace($contentUid)) { + throw "Each update entry must contain non-empty 'contentuid'." + } + + if (-not $nodeMap.ContainsKey($contentUid)) { + throw "Target russian.xml does not contain contentuid '$contentUid' for update." + } + + $node = $nodeMap[$contentUid] + if ($edit.PSObject.Properties.Name -contains "version" -and -not [string]::IsNullOrWhiteSpace([string]$edit.version)) { + $node.SetAttribute("version", [string]$edit.version) + } + + if ($edit.PSObject.Properties.Name -contains "text") { + $node.InnerText = [string]$edit.text + } + + $updatedEntries.Add($contentUid) | Out-Null +} + +$adds = @() +if ($edits.PSObject.Properties.Name -contains "adds" -and $null -ne $edits.adds) { + $adds = @($edits.adds) +} + +if (($updates.Count -eq 0) -and ($adds.Count -eq 0)) { + if (Test-Path -LiteralPath $temporaryRussianPath) { + Remove-Item -LiteralPath $temporaryRussianPath -Force + } + Write-Host "[apply-translation-edits.ps1] No edits requested. Original russian.xml left unchanged." + return +} + +foreach ($edit in $adds) { + $contentUid = [string]$edit.contentuid + $version = [string]$edit.version + $text = [string]$edit.text + + if ([string]::IsNullOrWhiteSpace($contentUid)) { + throw "Each add entry must contain non-empty 'contentuid'." + } + + if ($nodeMap.ContainsKey($contentUid)) { + throw "Target russian.xml already contains contentuid '$contentUid'; use 'updates' instead of 'adds'." + } + + if ([string]::IsNullOrWhiteSpace($version)) { + throw "Add entry '$contentUid' must contain non-empty 'version'." + } + + if ([string]::IsNullOrWhiteSpace($text)) { + throw "Add entry '$contentUid' must contain non-empty 'text'." + } + + $newNode = $russianDocument.Xml.CreateElement("content") + $newNode.SetAttribute("contentuid", $contentUid) + $newNode.SetAttribute("version", $version) + $newNode.InnerText = $text + [void]$contentListNode.AppendChild($newNode) + $nodeMap[$contentUid] = $newNode + $addedEntries.Add($contentUid) | Out-Null +} + +$settings = New-Object System.Xml.XmlWriterSettings +$settings.Encoding = New-Object System.Text.UTF8Encoding($true) +$settings.Indent = $true +$settings.IndentChars = " " +$settings.NewLineChars = "`n" +$settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace + +$writer = [System.Xml.XmlWriter]::Create($russianDocument.Path, $settings) +try { + $russianDocument.Xml.WriteTo($writer) +} finally { + $writer.Dispose() +} + +try { + & $validateScriptPath -XmlPath $temporaryRussianPath + Move-Item -LiteralPath $temporaryRussianPath -Destination $RussianPath -Force +} finally { + if (Test-Path -LiteralPath $temporaryRussianPath) { + Remove-Item -LiteralPath $temporaryRussianPath -Force + } +} + +Write-Host "[apply-translation-edits.ps1] Updated entries: $($updatedEntries.Count); Added entries: $($addedEntries.Count)." +if ($updatedEntries.Count -gt 0) { + Write-Host "[apply-translation-edits.ps1] Updated contentuid: $($updatedEntries -join ', ')" +} +if ($addedEntries.Count -gt 0) { + Write-Host "[apply-translation-edits.ps1] Added contentuid: $($addedEntries -join ', ')" +} diff --git a/scripts/compare-translation.ps1 b/scripts/compare-translation.ps1 new file mode 100644 index 0000000..5ace86f --- /dev/null +++ b/scripts/compare-translation.ps1 @@ -0,0 +1,203 @@ +param( + [string]$EnglishPath = ".cache/upstream/english.xml", + [string]$RussianPath = "Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml", + [string]$OutputDir = "build/translation-diff" +) + +$ErrorActionPreference = "Stop" + +function Get-LocalizationEntries { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + if (-not (Test-Path -LiteralPath $resolvedPath)) { + throw "Localization XML was not found: '$resolvedPath'." + } + + [xml]$xml = Get-Content -LiteralPath $resolvedPath -Raw + $nodes = $xml.SelectNodes('/contentList/content') + if ($null -eq $nodes) { + throw "Localization XML does not contain '/contentList/content' nodes: '$resolvedPath'." + } + + $entries = @{} + foreach ($node in $nodes) { + $contentUid = [string]$node.GetAttribute("contentuid") + if ([string]::IsNullOrWhiteSpace($contentUid)) { + continue + } + + $entries[$contentUid] = [ordered]@{ + contentuid = $contentUid + version = [string]$node.GetAttribute("version") + text = [string]$node.InnerText + } + } + + return $entries +} + +$resolvedOutputDir = [System.IO.Path]::GetFullPath($OutputDir) +New-Item -ItemType Directory -Path $resolvedOutputDir -Force | Out-Null + +$englishEntries = Get-LocalizationEntries -Path $EnglishPath +$russianEntries = Get-LocalizationEntries -Path $RussianPath + +$englishKeys = [System.Collections.Generic.HashSet[string]]::new([string[]]$englishEntries.Keys) +$russianKeys = [System.Collections.Generic.HashSet[string]]::new([string[]]$russianEntries.Keys) + +$missingInRussian = New-Object System.Collections.Generic.List[object] +$versionMismatch = New-Object System.Collections.Generic.List[object] +$staleOnlyInRussian = New-Object System.Collections.Generic.List[object] + +foreach ($contentUid in ($englishEntries.Keys | Sort-Object)) { + $englishEntry = $englishEntries[$contentUid] + if (-not $russianEntries.ContainsKey($contentUid)) { + $missingInRussian.Add([pscustomobject]@{ + contentuid = $contentUid + englishVersion = $englishEntry.version + englishText = $englishEntry.text + }) | Out-Null + continue + } + + $russianEntry = $russianEntries[$contentUid] + if ($englishEntry.version -ne $russianEntry.version) { + $versionMismatch.Add([pscustomobject]@{ + contentuid = $contentUid + englishVersion = $englishEntry.version + russianVersion = $russianEntry.version + englishText = $englishEntry.text + russianText = $russianEntry.text + }) | Out-Null + } +} + +foreach ($contentUid in ($russianEntries.Keys | Sort-Object)) { + if (-not $englishEntries.ContainsKey($contentUid)) { + $russianEntry = $russianEntries[$contentUid] + $staleOnlyInRussian.Add([pscustomobject]@{ + contentuid = $contentUid + russianVersion = $russianEntry.version + russianText = $russianEntry.text + }) | Out-Null + } +} + +$summary = [ordered]@{ + generatedAt = (Get-Date).ToString("o") + englishPath = [System.IO.Path]::GetFullPath($EnglishPath) + russianPath = [System.IO.Path]::GetFullPath($RussianPath) + englishCount = $englishEntries.Count + russianCount = $russianEntries.Count + missingInRussianCount = $missingInRussian.Count + versionMismatchCount = $versionMismatch.Count + staleOnlyInRussianCount = $staleOnlyInRussian.Count + missingInRussian = $missingInRussian + versionMismatch = $versionMismatch + staleOnlyInRussian = $staleOnlyInRussian +} + +$summaryJsonPath = Join-Path $resolvedOutputDir "summary.json" +$summaryMdPath = Join-Path $resolvedOutputDir "summary.md" +$candidatesJsonPath = Join-Path $resolvedOutputDir "candidates.json" + +$summary | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $summaryJsonPath -Encoding utf8 + +$candidates = [ordered]@{ + generatedAt = (Get-Date).ToString("o") + source = [ordered]@{ + englishPath = [System.IO.Path]::GetFullPath($EnglishPath) + russianPath = [System.IO.Path]::GetFullPath($RussianPath) + } + updates = @( + $versionMismatch | ForEach-Object { + [ordered]@{ + contentuid = $_.contentuid + version = $_.englishVersion + text = $_.russianText + englishText = $_.englishText + russianVersion = $_.russianVersion + } + } + ) + adds = @( + $missingInRussian | ForEach-Object { + [ordered]@{ + contentuid = $_.contentuid + version = $_.englishVersion + text = "" + englishText = $_.englishText + } + } + ) +} + +$candidates | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $candidatesJsonPath -Encoding utf8 + +$isUpToDate = ($missingInRussian.Count -eq 0) -and ($versionMismatch.Count -eq 0) -and ($staleOnlyInRussian.Count -eq 0) + +$mdLines = @( + "# Translation diff summary", + "", + "- Generated: $($summary.generatedAt)", + "- English entries: $($summary.englishCount)", + "- Russian entries: $($summary.russianCount)", + "- Missing in Russian: $($summary.missingInRussianCount)", + "- Version mismatches: $($summary.versionMismatchCount)", + "- Stale only in Russian: $($summary.staleOnlyInRussianCount)", + "" +) + +if ($isUpToDate) { + $mdLines += "Перевод уже актуален, дополнительные действия не требуются." +} else { + $mdLines += "" + $mdLines += "## Local workflow" + $mdLines += "1. Update upstream cache: ``scripts/get-upstream-english.ps1``" + $mdLines += "2. Refresh diff: ``scripts/compare-translation.ps1``" + $mdLines += "3. Edit ``build/translation-diff/candidates.json``" + $mdLines += "4. Apply changes: ``scripts/apply-translation-edits.ps1 -EditsPath build/translation-diff/candidates.json``" +} + +$mdLines += "" +$mdLines += "## Missing in Russian" +if ($missingInRussian.Count -eq 0) { + $mdLines += "- none" +} else { + $mdLines += ($missingInRussian | Select-Object -First 50 | ForEach-Object { + "- ``$($_.contentuid)`` v$($_.englishVersion): $($_.englishText)" + }) +} + +$mdLines += "" +$mdLines += "## Version mismatches" +if ($versionMismatch.Count -eq 0) { + $mdLines += "- none" +} else { + $mdLines += ($versionMismatch | Select-Object -First 50 | ForEach-Object { + "- ``$($_.contentuid)`` en=v$($_.englishVersion), ru=v$($_.russianVersion)" + }) +} + +$mdLines += "" +$mdLines += "## Stale only in Russian" +if ($staleOnlyInRussian.Count -eq 0) { + $mdLines += "- none" +} else { + $mdLines += ($staleOnlyInRussian | Select-Object -First 50 | ForEach-Object { + "- ``$($_.contentuid)`` v$($_.russianVersion): $($_.russianText)" + }) +} + +Set-Content -LiteralPath $summaryMdPath -Value $mdLines -Encoding utf8 + +Write-Host "[compare-translation.ps1] Summary written to '$summaryJsonPath' and '$summaryMdPath'." +Write-Host "[compare-translation.ps1] Editable candidate file written to '$candidatesJsonPath'." +Write-Host "[compare-translation.ps1] Missing=$($missingInRussian.Count); VersionMismatch=$($versionMismatch.Count); StaleOnlyInRussian=$($staleOnlyInRussian.Count)." +if ($isUpToDate) { + Write-Host "[compare-translation.ps1] Перевод уже актуален, дополнительные действия не требуются." +} diff --git a/scripts/get-upstream-english.ps1 b/scripts/get-upstream-english.ps1 new file mode 100644 index 0000000..1c7acc6 --- /dev/null +++ b/scripts/get-upstream-english.ps1 @@ -0,0 +1,44 @@ +param( + [string]$UpstreamEnglishUrl = "https://raw.githubusercontent.com/Yoonmoonsik/dnd55e/main/Mods/DnD2024_897914ef-5c96-053c-44af-0be823f895fe/Localization/English/english.xml", + [string]$OutputPath = ".cache/upstream/english.xml", + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($UpstreamEnglishUrl)) { + throw "UpstreamEnglishUrl must not be empty." +} + +$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath) +$outputDirectory = Split-Path -Parent $resolvedOutputPath +if (-not (Test-Path -LiteralPath $outputDirectory)) { + New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null +} + +$requestParameters = @{ + Uri = $UpstreamEnglishUrl + OutFile = $resolvedOutputPath + UseBasicParsing = $true + TimeoutSec = 120 +} + +if ($Force) { + $requestParameters["Headers"] = @{ + "Cache-Control" = "no-cache" + } +} + +Invoke-WebRequest @requestParameters + +[xml]$englishXml = Get-Content -LiteralPath $resolvedOutputPath -Raw +$rootNode = $englishXml.SelectSingleNode('/contentList') +if ($null -eq $rootNode) { + throw "Downloaded file is not a valid BG3 localization XML: '$resolvedOutputPath'." +} + +$contentCount = $englishXml.SelectNodes('/contentList/content').Count +$fileInfo = Get-Item -LiteralPath $resolvedOutputPath + +Write-Host "[get-upstream-english.ps1] Saved upstream english.xml to '$resolvedOutputPath'." +Write-Host "[get-upstream-english.ps1] Entries: $contentCount; Size: $($fileInfo.Length) bytes; Updated: $($fileInfo.LastWriteTime.ToString("o"))." diff --git a/scripts/update-translation.ps1 b/scripts/update-translation.ps1 new file mode 100644 index 0000000..3fca16f --- /dev/null +++ b/scripts/update-translation.ps1 @@ -0,0 +1,70 @@ +param( + [string]$RussianPath = "Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml", + [string]$EnglishPath = ".cache/upstream/english.xml", + [string]$OutputDir = "build/translation-diff", + [string]$EditsPath = "" +) + +$ErrorActionPreference = "Stop" + +$getUpstreamScriptPath = Join-Path $PSScriptRoot "get-upstream-english.ps1" +$compareScriptPath = Join-Path $PSScriptRoot "compare-translation.ps1" +$applyScriptPath = Join-Path $PSScriptRoot "apply-translation-edits.ps1" + +foreach ($scriptPath in @($getUpstreamScriptPath, $compareScriptPath, $applyScriptPath)) { + if (-not (Test-Path -LiteralPath $scriptPath)) { + throw "Required script was not found: '$scriptPath'." + } +} + +$resolvedOutputDir = [System.IO.Path]::GetFullPath($OutputDir) +$resolvedProvidedEditsPath = "" +if (-not [string]::IsNullOrWhiteSpace($EditsPath)) { + $resolvedProvidedEditsPath = [System.IO.Path]::GetFullPath($EditsPath) +} +$workingDiffDir = Join-Path $env:TEMP ("bg3-translation-update-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $workingDiffDir -Force | Out-Null + +try { + & $getUpstreamScriptPath -OutputPath $EnglishPath -Force + & $compareScriptPath -EnglishPath $EnglishPath -RussianPath $RussianPath -OutputDir $workingDiffDir + + New-Item -ItemType Directory -Path $resolvedOutputDir -Force | Out-Null + Get-ChildItem -LiteralPath $workingDiffDir | ForEach-Object { + $destinationPath = Join-Path $resolvedOutputDir $_.Name + if ($resolvedProvidedEditsPath -and ([System.IO.Path]::GetFullPath($destinationPath) -eq $resolvedProvidedEditsPath) -and (Test-Path -LiteralPath $resolvedProvidedEditsPath)) { + return + } + Copy-Item -LiteralPath $_.FullName -Destination $resolvedOutputDir -Recurse -Force + } + + $summaryJsonPath = Join-Path $workingDiffDir "summary.json" + if (-not (Test-Path -LiteralPath $summaryJsonPath)) { + throw "Translation summary was not found: '$summaryJsonPath'." + } + + $summary = Get-Content -LiteralPath $summaryJsonPath -Raw | ConvertFrom-Json -Depth 10 + $hasDiff = ($summary.missingInRussianCount -gt 0) -or ($summary.versionMismatchCount -gt 0) -or ($summary.staleOnlyInRussianCount -gt 0) + + if (-not $hasDiff) { + Write-Host "[update-translation.ps1] Перевод уже актуален, дополнительные действия не требуются." + return + } + + $effectiveEditsPath = $EditsPath + if ([string]::IsNullOrWhiteSpace($effectiveEditsPath)) { + $effectiveEditsPath = Join-Path $resolvedOutputDir "candidates.json" + } + + if (-not (Test-Path -LiteralPath $effectiveEditsPath)) { + throw "Translation changes were found. Prepare edits in '$([System.IO.Path]::GetFullPath((Join-Path $resolvedOutputDir "candidates.json")))' and rerun update with '-EditsPath'." + } + + & $applyScriptPath -RussianPath $RussianPath -EditsPath $effectiveEditsPath + + Write-Host "[update-translation.ps1] Обновление перевода завершено. Результат записан в '$([System.IO.Path]::GetFullPath($RussianPath))'." +} finally { + if (Test-Path -LiteralPath $workingDiffDir) { + Remove-Item -LiteralPath $workingDiffDir -Recurse -Force + } +} diff --git a/scripts/validate-translation-xml.ps1 b/scripts/validate-translation-xml.ps1 new file mode 100644 index 0000000..e2094b7 --- /dev/null +++ b/scripts/validate-translation-xml.ps1 @@ -0,0 +1,41 @@ +param( + [Parameter(Mandatory = $true)] + [string]$XmlPath +) + +$ErrorActionPreference = "Stop" + +$resolvedXmlPath = [System.IO.Path]::GetFullPath($XmlPath) +if (-not (Test-Path -LiteralPath $resolvedXmlPath)) { + throw "XML file was not found: '$resolvedXmlPath'." +} + +[xml]$xml = Get-Content -LiteralPath $resolvedXmlPath -Raw +$contentListNode = $xml.SelectSingleNode('/contentList') +if ($null -eq $contentListNode) { + throw "XML validation failed: missing '/contentList' in '$resolvedXmlPath'." +} + +$contentNodes = $xml.SelectNodes('/contentList/content') +if ($null -eq $contentNodes -or $contentNodes.Count -lt 1) { + throw "XML validation failed: no '/contentList/content' entries found in '$resolvedXmlPath'." +} + +$seen = [System.Collections.Generic.HashSet[string]]::new() +foreach ($node in $contentNodes) { + $contentUid = [string]$node.GetAttribute("contentuid") + if ([string]::IsNullOrWhiteSpace($contentUid)) { + throw "XML validation failed: found content node without 'contentuid' in '$resolvedXmlPath'." + } + + if (-not $seen.Add($contentUid)) { + throw "XML validation failed: duplicate contentuid '$contentUid' in '$resolvedXmlPath'." + } + + $version = [string]$node.GetAttribute("version") + if ([string]::IsNullOrWhiteSpace($version)) { + throw "XML validation failed: contentuid '$contentUid' has empty 'version' in '$resolvedXmlPath'." + } +} + +Write-Host "[validate-translation-xml.ps1] XML is valid: '$resolvedXmlPath'. Entries=$($contentNodes.Count)."