Автоматизировал локальное обновление перевода

This commit is contained in:
2026-04-09 13:02:40 +03:00
parent c40712701c
commit 5d54da9048
7 changed files with 591 additions and 5 deletions

View File

@@ -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 ', ')"
}

View File

@@ -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] Перевод уже актуален, дополнительные действия не требуются."
}

View File

@@ -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"))."

View File

@@ -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
}
}

View File

@@ -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)."