Compare commits
5 Commits
v0.2.3
...
4e99dfdd92
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e99dfdd92 | |||
| 5d54da9048 | |||
| d96db4e1f0 | |||
| c40712701c | |||
| c9595312ab |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
build/
|
build/
|
||||||
build-stage*
|
build-stage*
|
||||||
.tools/
|
.tools/
|
||||||
|
.cache/
|
||||||
*.pak
|
*.pak
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|||||||
51
ACTIONS.md
51
ACTIONS.md
@@ -28,6 +28,45 @@ REPORT_FORMAT:
|
|||||||
- remaining
|
- remaining
|
||||||
|
|
||||||
ACTIONS:
|
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:
|
translation:update:
|
||||||
intent: sync_ru_translation_with_upstream
|
intent: sync_ru_translation_with_upstream
|
||||||
inputs:
|
inputs:
|
||||||
@@ -35,16 +74,18 @@ ACTIONS:
|
|||||||
- glossary/glossary.normalized.json
|
- glossary/glossary.normalized.json
|
||||||
- AGENTS.md::Canonical Paths::Upstream English reference
|
- AGENTS.md::Canonical Paths::Upstream English reference
|
||||||
plan:
|
plan:
|
||||||
- compare_en_vs_ru_by_keys
|
- refresh_upstream_english_cache
|
||||||
- classify_diff_into_new_changed_stale_candidate
|
- compare_en_vs_ru_by_contentuid_and_version
|
||||||
- update_ru_for_new_and_changed_using_glossary
|
- if_no_diff_report_translation_is_up_to_date_and_stop
|
||||||
- validate_xml_structure_and_service_attributes
|
- else_apply_prepared_edits_to_temporary_russian_copy
|
||||||
- prepare_delta_summary_counts
|
- validate_temporary_xml_via_separate_script
|
||||||
|
- replace_original_russian_xml_after_successful_validation
|
||||||
checks:
|
checks:
|
||||||
- xml_valid
|
- xml_valid
|
||||||
- glossary_consistency
|
- glossary_consistency
|
||||||
- scope_limited_to_localization_and_allowed_metadata
|
- scope_limited_to_localization_and_allowed_metadata
|
||||||
outputs:
|
outputs:
|
||||||
|
- message: translation_up_to_date
|
||||||
- Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
|
- Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
|
||||||
- optional: Mods/DnD 5.5e AIO Russian/meta.lsx (release-only)
|
- optional: Mods/DnD 5.5e AIO Russian/meta.lsx (release-only)
|
||||||
after_success:
|
after_success:
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
### Git Collaboration Policy (General)
|
### Git Collaboration Policy (General)
|
||||||
- Commit/push only after explicit user approval.
|
- Commit/push only after explicit user approval.
|
||||||
- After approval: commit and push immediately.
|
- After approval: commit and push immediately.
|
||||||
- Branch switch prompt (`fix/*` or `feat/*`): ask once at dialogue start; reuse the explicit user decision for all subsequent fix/feature tasks in the same dialogue.
|
- Branch switch prompt (`fix/*` or `feat/*`): ask at dialogue start; reuse the explicit user decision for all subsequent fix/feature tasks in the same dialogue.
|
||||||
|
- Pending clarification/approval question: ask once, in a single short message; do not repeat the same pending question in a separate final message.
|
||||||
- After finishing work in `fix/*` or `feat/*`: propose either
|
- After finishing work in `fix/*` or `feat/*`: propose either
|
||||||
1. creating an MR into `main`, or
|
1. creating an MR into `main`, or
|
||||||
2. merging to `main` immediately and deleting the `fix/*`/`feat/*` branch.
|
2. merging to `main` immediately and deleting the `fix/*`/`feat/*` branch.
|
||||||
@@ -22,6 +23,9 @@
|
|||||||
- For changes to rules files (`AGENTS.md`, `ACTIONS.md`): prefer optimized, compressed edits for AI-agent execution (machine-readable, unambiguous).
|
- For changes to rules files (`AGENTS.md`, `ACTIONS.md`): prefer optimized, compressed edits for AI-agent execution (machine-readable, unambiguous).
|
||||||
- Keep rule updates minimal and non-duplicative: merge overlapping points, remove redundancy, preserve intent.
|
- Keep rule updates minimal and non-duplicative: merge overlapping points, remove redundancy, preserve intent.
|
||||||
|
|
||||||
|
### Communication (General)
|
||||||
|
- Project file links in user-facing Markdown: relative paths, `/` separators, spaces encoded as `%20`.
|
||||||
|
|
||||||
## Project-Specific Rules (MUST)
|
## Project-Specific Rules (MUST)
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
@@ -63,7 +67,7 @@
|
|||||||
- CI triggers: tag `v*` and manual dispatch; not every push to `main`.
|
- CI triggers: tag `v*` and manual dispatch; not every push to `main`.
|
||||||
|
|
||||||
### Version/Release Rules
|
### Version/Release Rules
|
||||||
- Read release version only from `save/region/node[@id="ModuleSettings"]/children/node[@id="ModuleInfo"]/attribute[@id="Version64"]` via explicit XML parsing.
|
- Read release version only from `save/region[@id="Config"]/node[@id="root"]/children/node[@id="ModuleInfo"]/attribute[@id="Version64"]` via explicit XML parsing.
|
||||||
- `PublishVersion` must not be changed during release preparation.
|
- `PublishVersion` must not be changed during release preparation.
|
||||||
- Release tag must match the source-of-truth version.
|
- Release tag must match the source-of-truth version.
|
||||||
- Decision logic before tagging:
|
- Decision logic before tagging:
|
||||||
|
|||||||
186
scripts/apply-translation-edits.ps1
Normal file
186
scripts/apply-translation-edits.ps1
Normal 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 ', ')"
|
||||||
|
}
|
||||||
203
scripts/compare-translation.ps1
Normal file
203
scripts/compare-translation.ps1
Normal 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] Перевод уже актуален, дополнительные действия не требуются."
|
||||||
|
}
|
||||||
44
scripts/get-upstream-english.ps1
Normal file
44
scripts/get-upstream-english.ps1
Normal 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"))."
|
||||||
70
scripts/update-translation.ps1
Normal file
70
scripts/update-translation.ps1
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
41
scripts/validate-translation-xml.ps1
Normal file
41
scripts/validate-translation-xml.ps1
Normal 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)."
|
||||||
Reference in New Issue
Block a user