Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f8233ab0b | |||
| af1fd046d4 | |||
| d82c627bb1 | |||
| d17bd723e3 | |||
| fe8c50ff80 | |||
| f9289b4384 | |||
| 5ac424770d | |||
| a29c22af12 | |||
| b684bf195e | |||
| 39ac7a54ce | |||
| 78a9840349 | |||
| 92c78f0813 | |||
| 07c7fc8aa7 | |||
| cb37f422ac | |||
| 72bab59520 | |||
| 1ca6d579da | |||
| 6ede25dc35 | |||
| de85438afe | |||
| 111cf8c269 | |||
| d2299199ba | |||
| ec062817dc | |||
| f56e10748e | |||
| ac1bd03426 | |||
| 07f557ca30 | |||
| 7f189aa741 | |||
| d048a33c55 | |||
| 4e99dfdd92 | |||
| 5d54da9048 | |||
| d96db4e1f0 | |||
| c40712701c | |||
| c9595312ab | |||
| 91e12e4ba1 | |||
| 921a5a3156 | |||
| 2ab32b258c | |||
| 175c1dbaed | |||
| e9bfbfe74f | |||
| 6782cfcd87 | |||
| eaf84ad605 | |||
| 65e3f5b48e | |||
| 9519b92771 | |||
| 6257027a13 | |||
| cde9194ed5 | |||
| 1c8cf13f67 | |||
| 74a8942999 | |||
| 7f8f09a3ac | |||
| 70f93c3d29 | |||
| 4aa2e136b2 | |||
| 3ae30a5263 | |||
| 4da26911fe | |||
| df1daee6ab | |||
| 9d1a26c8e0 | |||
| 8a4970742c | |||
| b50a6a2f95 | |||
| 4646b51459 | |||
| a72b7bc1e1 | |||
| 7aca648396 | |||
| 36129b15d1 | |||
| 97ca95ba16 | |||
| ad129e15d5 | |||
| c8371a3fec | |||
| 321fef2f63 |
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
BOT_TOKEN=
|
||||
TG_CHAT_ID=
|
||||
TG_THREAD_ID=
|
||||
@@ -13,6 +13,9 @@ jobs:
|
||||
build:
|
||||
runs-on:
|
||||
- win11
|
||||
env:
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
TG_THREAD_ID: ${{ secrets.TG_THREAD_ID }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -40,6 +43,7 @@ jobs:
|
||||
}
|
||||
|
||||
git -c "http.extraHeader=Authorization: Basic $authBasic" fetch --depth 1 origin $repoRef
|
||||
git -c "http.extraHeader=Authorization: Basic $authBasic" fetch --force --tags origin
|
||||
git checkout --force FETCH_HEAD
|
||||
|
||||
if (-not (Test-Path -LiteralPath "Mods\\DnD 5.5e AIO Russian\\Localization\\Russian\\russian.xml")) {
|
||||
@@ -49,6 +53,17 @@ jobs:
|
||||
New-Item -ItemType Directory -Path ".tools\\lslib" -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path "build" -Force | Out-Null
|
||||
|
||||
- name: Notify Telegram about build start
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$runUrl = "${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||
$text = "⏳ <b>Старт сборки релиза</b>`n<b>Репозиторий:</b> <code>${{ gitea.repository }}</code>`n<b>Тег:</b> <code>${{ gitea.ref_name }}</code>`n<a href=`"$runUrl`">Открыть лог сборки</a>"
|
||||
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text -DisableNotification
|
||||
|
||||
- name: Download latest LSLib release
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -109,6 +124,7 @@ jobs:
|
||||
throw "Release archive was not found at '$zipPath'."
|
||||
}
|
||||
|
||||
$isPrerelease = $tagName -match '^v\d+\.\d+\.\d+-'
|
||||
$owner = $repoParts[0]
|
||||
$repo = $repoParts[1]
|
||||
$apiBase = "$serverUrl/api/v1/repos/$owner/$repo"
|
||||
@@ -137,10 +153,19 @@ jobs:
|
||||
name = $tagName
|
||||
target_commitish = "${{ gitea.sha }}"
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
prerelease = $isPrerelease
|
||||
} | ConvertTo-Json
|
||||
|
||||
$release = Invoke-RestMethod -Method Post -Uri "$apiBase/releases" -Headers $headers -ContentType "application/json" -Body $releaseBody
|
||||
} elseif ($release.prerelease -ne $isPrerelease) {
|
||||
$releaseBody = @{
|
||||
tag_name = $tagName
|
||||
target_commitish = "${{ gitea.sha }}"
|
||||
draft = $false
|
||||
prerelease = $isPrerelease
|
||||
} | ConvertTo-Json
|
||||
|
||||
$release = Invoke-RestMethod -Method Patch -Uri "$apiBase/releases/$($release.id)" -Headers $headers -ContentType "application/json" -Body $releaseBody
|
||||
}
|
||||
|
||||
$existingAsset = $null
|
||||
@@ -158,3 +183,25 @@ jobs:
|
||||
}
|
||||
|
||||
Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$apiBase/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($assetName))" -Headers $uploadHeaders -ContentType "application/octet-stream" -InFile $zipPath
|
||||
|
||||
- name: Notify Telegram about build success
|
||||
if: success() && startsWith(gitea.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$releaseUrl = "${{ gitea.server_url }}/${{ gitea.repository }}/releases/tag/${{ gitea.ref_name }}"
|
||||
$text = "🏁 <b>Релиз собран успешно</b>`n<b>Тег:</b> <code>${{ gitea.ref_name }}</code>`n<a href=`"$releaseUrl`">Открыть готовый релиз</a>"
|
||||
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text -DisableNotification
|
||||
|
||||
- name: Notify Telegram about build failure
|
||||
if: failure() && startsWith(gitea.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$runUrl = "${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||
$text = "❌ <b>Сборка релиза завершилась ошибкой</b>`n<b>Тег:</b> <code>${{ gitea.ref_name }}</code>`n<a href=`"$runUrl`">Открыть лог сборки</a>"
|
||||
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text
|
||||
|
||||
186
.github/workflows/build.yml
vendored
Normal file
186
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,186 @@
|
||||
name: Build Mod Package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
environment: Configure TgBot
|
||||
env:
|
||||
TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
|
||||
TG_THREAD_ID: ${{ secrets.TG_THREAD_ID }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Prepare workspace
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path -LiteralPath "Mods\\DnD 5.5e AIO Russian\\Localization\\Russian\\russian.xml")) {
|
||||
throw "Repository sources are not available in the runner workspace."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path ".tools\\lslib" -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path "build" -Force | Out-Null
|
||||
|
||||
- name: Resolve version tag
|
||||
id: vars
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$versionTag = ""
|
||||
$isPrerelease = "false"
|
||||
if ($env:GITHUB_REF -like "refs/tags/v*") {
|
||||
$versionTag = $env:GITHUB_REF_NAME
|
||||
if ($versionTag -match '^v\d+\.\d+\.\d+-') {
|
||||
$isPrerelease = "true"
|
||||
}
|
||||
}
|
||||
"version_tag=$versionTag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
"is_prerelease=$isPrerelease" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
|
||||
|
||||
- name: Notify Telegram about build start
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$target = if ("${{ steps.vars.outputs.version_tag }}") { "${{ steps.vars.outputs.version_tag }}" } else { "${{ github.ref_name }}" }
|
||||
$runUrl = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
$text = "⏳ <b>Старт сборки релиза</b>`n<b>Репозиторий:</b> <code>${{ github.repository }}</code>`n<b>Тег:</b> <code>$target</code>`n<a href=`"$runUrl`">Открыть лог сборки</a>"
|
||||
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text -DisableNotification
|
||||
|
||||
- name: Download latest LSLib release
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/Norbyte/lslib/releases/latest"
|
||||
$asset = $release.assets | Where-Object { $_.name -match '\.zip$' } | Select-Object -First 1
|
||||
|
||||
if (-not $asset) {
|
||||
throw "Could not find a downloadable LSLib zip asset in the latest release."
|
||||
}
|
||||
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $asset.browser_download_url -OutFile ".tools/lslib/lslib.zip"
|
||||
Expand-Archive -LiteralPath ".tools/lslib/lslib.zip" -DestinationPath ".tools/lslib" -Force
|
||||
|
||||
- name: Build .pak
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$divine = Get-ChildItem -Path ".tools\\lslib" -Recurse -File |
|
||||
Where-Object { $_.Name -ieq "Divine.exe" } |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $divine) {
|
||||
throw "Divine.exe was not found in the downloaded LSLib release."
|
||||
}
|
||||
|
||||
& ".\\scripts\\build.ps1" -DivinePath $divine.FullName -Workspace "${{ github.workspace }}" -VersionTag "${{ steps.vars.outputs.version_tag }}"
|
||||
|
||||
- name: Show build result
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$archiveBaseName = "DnD 5.5e AIO Russian"
|
||||
if ($env:GITHUB_REF -like "refs/tags/v*") {
|
||||
$archiveBaseName = "DnD 5.5e AIO Russian $env:GITHUB_REF_NAME"
|
||||
}
|
||||
$zipPath = [System.IO.Path]::GetFullPath((Join-Path "${{ github.workspace }}" "build/$archiveBaseName.zip"))
|
||||
|
||||
Get-ChildItem "build/DnD 5.5e AIO Russian.pak", "build/info.json", $zipPath |
|
||||
Select-Object FullName, Length, LastWriteTime
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bg3-dnd55e-russian-localization-${{ github.run_number }}
|
||||
path: build/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create or update GitHub release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$tagName = "${{ github.ref_name }}"
|
||||
$zipPath = Join-Path $env:GITHUB_WORKSPACE "build\\DnD 5.5e AIO Russian $tagName.zip"
|
||||
$isPrerelease = "${{ steps.vars.outputs.is_prerelease }}" -eq "true"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $zipPath)) {
|
||||
throw "Release archive was not found at '$zipPath'."
|
||||
}
|
||||
|
||||
gh release view $tagName --repo "${{ github.repository }}" *> $null
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$args = @(
|
||||
"release", "create", $tagName, $zipPath,
|
||||
"--repo", "${{ github.repository }}",
|
||||
"--title", $tagName,
|
||||
"--notes", ""
|
||||
)
|
||||
|
||||
if ($isPrerelease) {
|
||||
$args += "--prerelease"
|
||||
}
|
||||
|
||||
gh @args
|
||||
}
|
||||
else {
|
||||
gh release upload $tagName $zipPath `
|
||||
--repo "${{ github.repository }}" `
|
||||
--clobber
|
||||
|
||||
$releaseId = gh release view $tagName `
|
||||
--repo "${{ github.repository }}" `
|
||||
--json id `
|
||||
--jq ".id"
|
||||
|
||||
$releaseBodyPath = Join-Path $env:RUNNER_TEMP "release-body.json"
|
||||
@{
|
||||
name = $tagName
|
||||
prerelease = $isPrerelease
|
||||
} | ConvertTo-Json -Compress | Set-Content -LiteralPath $releaseBodyPath -Encoding utf8
|
||||
|
||||
gh api `
|
||||
--method PATCH `
|
||||
-H "Accept: application/vnd.github+json" `
|
||||
"/repos/${{ github.repository }}/releases/$releaseId" `
|
||||
--input $releaseBodyPath
|
||||
}
|
||||
|
||||
- name: Notify Telegram about build success
|
||||
if: success() && startsWith(github.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$releaseUrl = "${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
|
||||
$text = "🏁 <b>Релиз собран успешно</b>`n<b>Тег:</b> <code>${{ github.ref_name }}</code>`n<a href=`"$releaseUrl`">Открыть готовый релиз</a>"
|
||||
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text -DisableNotification
|
||||
|
||||
- name: Notify Telegram about build failure
|
||||
if: failure() && startsWith(github.ref, 'refs/tags/v')
|
||||
continue-on-error: true
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$runUrl = "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
$text = "❌ <b>Сборка релиза завершилась ошибкой</b>`n<b>Тег:</b> <code>${{ github.ref_name }}</code>`n<a href=`"$runUrl`">Открыть лог сборки</a>"
|
||||
.\scripts\send-telegram-notification.ps1 -BotToken $env:BOT_TOKEN -ChatId $env:TG_CHAT_ID -ThreadId $env:TG_THREAD_ID -Text $text
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,11 @@
|
||||
build/
|
||||
build-stage*
|
||||
.tools/
|
||||
.cache/
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pak
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
137
ACTIONS.md
Normal file
137
ACTIONS.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# ACTIONS.md
|
||||
|
||||
VERSION: 3
|
||||
MODE: machine-first
|
||||
LANG: ru
|
||||
|
||||
ROUTING:
|
||||
- match_user_request_to_action_id: true
|
||||
- if_match: propose_action
|
||||
- if_no_match: ignore_actions_md
|
||||
- if_no_match_user_message: none
|
||||
|
||||
PROPOSE_RULE:
|
||||
- prompt_template: "Приступить к выполнению '{action_id}'?"
|
||||
- require_user_confirmation: true
|
||||
- execute_without_confirmation: false
|
||||
|
||||
EXECUTION_BASELINE:
|
||||
- enforce_agents_md: true
|
||||
- minimal_non_breaking_changes: true
|
||||
- steps_count_range: [3, 7]
|
||||
- before_commit_push: request_user_approval
|
||||
- prefer_existing_repo_scripts_over_manual_work: true
|
||||
|
||||
REPORT_FORMAT:
|
||||
- done
|
||||
- changed_files
|
||||
- checks
|
||||
- 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:
|
||||
- run_scripts/get-upstream-english.ps1_and_wait_until_output_exists
|
||||
- run_scripts/compare-translation.ps1_after_upstream_download_only
|
||||
- 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
|
||||
- translation_steps_not_parallelized_when_file_dependency_exists
|
||||
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
|
||||
- prepared_update_texts_for_updates_and_optional_adds
|
||||
plan:
|
||||
- create_temporary_copy_of_russian_xml
|
||||
- load_candidate_edit_file_and_temporary_xml
|
||||
- fail_if_add_entry_has_empty_text
|
||||
- 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
|
||||
- no_partial_replace_on_validation_failure
|
||||
outputs:
|
||||
- Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
|
||||
translation:update:
|
||||
intent: sync_ru_translation_with_upstream
|
||||
inputs:
|
||||
- Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
|
||||
- glossary/glossary.normalized.json
|
||||
- AGENTS.md::Canonical Paths::Upstream English reference
|
||||
plan:
|
||||
- run_translation:diff_sequentially
|
||||
- if_summary_has_no_missing_no_version_mismatch_no_stale_report_translation_up_to_date_and_stop
|
||||
- if_diff_exists_stop_after_generating_build/translation-diff/candidates.json_until_prepared_edits_are_provided_explicitly
|
||||
- review_build/translation-diff/candidates.json_before_apply
|
||||
- reuse_glossary_for_term_consistency_when_preparing_texts
|
||||
- run_translation:apply_only_after_candidate_texts_are_filled_and_explicit_edits_path_is_passed
|
||||
checks:
|
||||
- xml_valid
|
||||
- glossary_consistency
|
||||
- scope_limited_to_localization_and_allowed_metadata
|
||||
- no_upstream_download_compare_race_condition
|
||||
outputs:
|
||||
- message: translation_up_to_date
|
||||
- build/translation-diff/summary.json
|
||||
- build/translation-diff/summary.md
|
||||
- build/translation-diff/candidates.json
|
||||
- Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml
|
||||
- optional: Mods/DnD 5.5e AIO Russian/meta.lsx (release-only)
|
||||
after_success:
|
||||
- suggest_action: meta:sync-parent
|
||||
reason: "Обновить версию зависимости из родительского мода (актуальный Version64 и связанные поля зависимости)."
|
||||
|
||||
action:report:
|
||||
intent: unified_task_report
|
||||
inputs:
|
||||
- task_context
|
||||
- modified_files
|
||||
- verification_results
|
||||
plan:
|
||||
- summarize_done
|
||||
- list_changed_files
|
||||
- list_checks
|
||||
- list_remaining
|
||||
checks:
|
||||
- concise
|
||||
- factual
|
||||
- no_unverified_claims
|
||||
outputs:
|
||||
- final_user_report
|
||||
meta:sync-parent:
|
||||
intent: sync_dependency_moduleshortdesc_from_parent_meta
|
||||
inputs:
|
||||
- parent_meta_git_url (optional; defaults to upstream)
|
||||
- Mods/DnD 5.5e AIO Russian/meta.lsx
|
||||
plan:
|
||||
- read_parent_moduleinfo_fields
|
||||
- validate_required_fields_folder_md5_name_publishhandle_uuid_version64
|
||||
- update_target_dependencies_moduleshortdesc_fields
|
||||
- validate_xml_structure
|
||||
- report_changed_fields
|
||||
checks:
|
||||
- xml_valid
|
||||
- required_parent_fields_present
|
||||
- only_dependencies_moduleshortdesc_changed
|
||||
outputs:
|
||||
- Mods/DnD 5.5e AIO Russian/meta.lsx
|
||||
|
||||
287
AGENTS.md
287
AGENTS.md
@@ -1,153 +1,236 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
## Execution Model (MUST)
|
||||
- Read this file first; treat as system-level constraints.
|
||||
- Priority:
|
||||
1. User instructions
|
||||
2. AGENTS.md
|
||||
3. Existing code/style
|
||||
4. Best practices
|
||||
- Prefer minimal, non-breaking changes.
|
||||
- Do not introduce unnecessary abstractions.
|
||||
|
||||
This repository contains a standalone Russian localization mod for **Baldur's Gate 3**:
|
||||
---
|
||||
|
||||
- Mod name: `DnD 5.5e All-in-One BEYOND Russian Localization`
|
||||
- Mod folder: `Mods/DnD 5.5e AIO Russian`
|
||||
- Base/original mod dependency: `DnD 5.5e All-in-One BEYOND`
|
||||
- Original mod repository: `https://github.com/Yoonmoonsik/dnd55e`
|
||||
- Original dependency UUID: `897914ef-5c96-053c-44af-0be823f895fe`
|
||||
## Communication (MUST)
|
||||
- Answer first, then request approval if needed.
|
||||
- Concise, meaningful, no filler.
|
||||
- Do not end response with only procedural choice.
|
||||
|
||||
This repository is for the localization mod only. It must not gain gameplay logic, Script Extender files, or unrelated assets.
|
||||
Approval/clarification:
|
||||
- ask once, no repetition
|
||||
- binary → yes/no
|
||||
- multiple → numbered options + brief context
|
||||
|
||||
## Repository Rules
|
||||
- File links in repo docs/checklists: relative paths, `/`, spaces as `%20`.
|
||||
- In assistant UI responses, use the link format required by the execution environment; include relative path text when possible.
|
||||
|
||||
- Keep the repository source-only.
|
||||
- Do not commit `.pak` artifacts.
|
||||
- Do not commit temporary build outputs.
|
||||
- Do not add gameplay or script content unrelated to localization/release packaging.
|
||||
- Keep the localization folder and metadata consistent with the packaged mod.
|
||||
---
|
||||
|
||||
## Current Important Paths
|
||||
## Git Workflow (MUST)
|
||||
- Never commit/push without explicit user approval.
|
||||
- After approval → commit + push immediately.
|
||||
- Commit messages: Russian, factual (what was done).
|
||||
- Branch (`fix/*` or `feat/*`): ask once before the first file-changing task that may lead to commit; reuse decision for all subsequent tasks in same dialogue.
|
||||
|
||||
- Mod sources: `Mods/DnD 5.5e AIO Russian`
|
||||
- Localization XML: `Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml`
|
||||
- Glossary: `glossary/glossary.normalized.json`
|
||||
- Mod metadata: `Mods/DnD 5.5e AIO Russian/meta.lsx`
|
||||
- CI workflow: `.gitea/workflows/build.yml`
|
||||
- Main build script: `scripts/build.ps1`
|
||||
After work in `fix/*` or `feat/*`:
|
||||
1. create PR/MR targeting `main`
|
||||
2. merge changes into `main` and delete the source branch
|
||||
|
||||
## Build And Release Model
|
||||
Push failure:
|
||||
- retry ≤2 times, 3s delay
|
||||
|
||||
The authoritative build logic lives in:
|
||||
Release link:
|
||||
- provide `[version](url)` immediately after tag push, without waiting for CI
|
||||
|
||||
- `scripts/build.ps1`
|
||||
---
|
||||
|
||||
The Gitea workflow should stay thin and only:
|
||||
## Scope (MUST)
|
||||
- Repo = Russian localization mod only.
|
||||
|
||||
1. prepare the workspace
|
||||
2. download `Divine`
|
||||
3. call `scripts/build.ps1`
|
||||
4. publish the release zip for tag builds
|
||||
Allowed:
|
||||
- localization content
|
||||
- packaging/release metadata
|
||||
|
||||
### Current Build Outputs
|
||||
Forbidden:
|
||||
- gameplay logic
|
||||
- Script Extender
|
||||
- unrelated assets
|
||||
|
||||
The build script produces:
|
||||
- Repo must remain source-only.
|
||||
- Never commit `.pak` or build artifacts.
|
||||
|
||||
- `build/DnD 5.5e AIO Russian.pak`
|
||||
---
|
||||
|
||||
## Paths (MUST)
|
||||
- Mod: `Mods/DnD 5.5e AIO Russian`
|
||||
- Localization: `Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml`
|
||||
- Metadata: `Mods/DnD 5.5e AIO Russian/meta.lsx`
|
||||
- Build: `scripts/build.ps1` _(single source of build truth)_
|
||||
- CI: `.gitea/workflows/build.yml`
|
||||
- Glossary: `glossary/glossary.normalized.json` _(primary terminology reference)_
|
||||
- Actions: `ACTIONS.md`
|
||||
- Local env template: `.env.example`
|
||||
- Local env file: `.env.local`
|
||||
- Upstream EN reference: `https://github.com/Yoonmoonsik/dnd55e/blob/main/Mods/DnD2024_897914ef-5c96-053c-44af-0be823f895fe/Localization/English/english.xml`
|
||||
|
||||
---
|
||||
|
||||
## Packaging (MUST)
|
||||
- `.pak` contains ONLY `Mods/...`
|
||||
|
||||
Required:
|
||||
- `meta.lsx`
|
||||
- `russian.xml`
|
||||
|
||||
Forbidden in `.pak`:
|
||||
- `.git`, `.gitea`
|
||||
- `scripts`, `tools`, `.tools`
|
||||
- `build`, staging dirs
|
||||
|
||||
Staging:
|
||||
- use `%TEMP%`
|
||||
- not inside repo
|
||||
|
||||
---
|
||||
|
||||
## Build & CI (MUST)
|
||||
Flow:
|
||||
1. prepare
|
||||
2. download Divine
|
||||
3. run `scripts/build.ps1`
|
||||
4. publish
|
||||
|
||||
Outputs:
|
||||
- `build/*.pak`
|
||||
- `build/info.json`
|
||||
- `build/DnD 5.5e AIO Russian <tag>.zip` for tagged builds
|
||||
- `build/*.zip` (tag only)
|
||||
|
||||
The release zip is expected to contain:
|
||||
Release ZIP:
|
||||
- only `.pak` + `info.json`
|
||||
|
||||
- the built `.pak`
|
||||
- `info.json`
|
||||
Triggers:
|
||||
- automatic: push tag `v*`
|
||||
- manual: workflow_dispatch
|
||||
- branch pushes without tag MUST NOT publish release artifacts
|
||||
|
||||
## Packaging Notes
|
||||
---
|
||||
|
||||
The package must contain only the BG3 mod structure under `Mods/...`.
|
||||
## Versioning (CRITICAL)
|
||||
Source of truth:
|
||||
`ModuleInfo/Version64` — read via explicit XML parsing:
|
||||
`save/region[@id="Config"]/node[@id="root"]/children/node[@id="ModuleInfo"]/attribute[@id="Version64"]`
|
||||
|
||||
Verified expected extracted `.pak` structure:
|
||||
Rules:
|
||||
- do not change `PublishVersion`
|
||||
- tag MUST match version
|
||||
- tag formats:
|
||||
- stable: `vX.Y.Z` -> `Version64 = X.Y.Z.0`
|
||||
- suffixed: `vX.Y.Z-suffix` -> `Version64 = X.Y.Z.N`
|
||||
- for suffixed tags, suffix affects tag/release channel only and is NOT encoded in `Version64`
|
||||
- for suffixed tags on the same base version `X.Y.Z`, increment `build` (`N`) by counting prior released tags `vX.Y.Z-*`; current release uses the next value starting from `1`
|
||||
- stable tag without suffix always uses `build = 0`, even if suffixed releases for the same base version already existed
|
||||
|
||||
- `Mods/DnD 5.5e AIO Russian/meta.lsx`
|
||||
- `Mods/DnD 5.5e AIO Russian/Localization/Russian/russian.xml`
|
||||
Before tag:
|
||||
1. if version already changed → use it
|
||||
2. if same as last → bump:
|
||||
`scripts/set-version.ps1 -VersionTag <tag>`
|
||||
|
||||
Do not allow `.git`, `.gitea`, `scripts`, `tools`, `.tools`, `build`, or staging directories into the `.pak`.
|
||||
`build.ps1`:
|
||||
- derives version from tag
|
||||
- writes to `info.json` + staged `meta.lsx`
|
||||
|
||||
## Important Packaging Behavior
|
||||
Conflict resolution (MUST):
|
||||
- before release, `Version64` in `meta.lsx` MUST equal target tag version
|
||||
- if mismatch, run `scripts/set-version.ps1 -VersionTag <tag>` and re-check
|
||||
- if still mismatch, release is blocked
|
||||
|
||||
There is a runner-specific packaging quirk:
|
||||
---
|
||||
|
||||
- `Divine` can produce a broken 48-byte `.pak` on the CI runner depending on the source path.
|
||||
- Current mitigation is implemented in `scripts/build.ps1`.
|
||||
- The script uses staged sources and fallback packaging attempts.
|
||||
- Staging is performed in `%TEMP%`, not in a dot-prefixed directory inside the repo.
|
||||
## info.json (MUST)
|
||||
Root:
|
||||
- `Mods`, `MD5`
|
||||
|
||||
If packaging breaks again, debug the source path and unpack the resulting `.pak` locally to verify actual contents.
|
||||
Per mod:
|
||||
- Author, Name, Folder, Version
|
||||
- Description, UUID, Created
|
||||
- Dependencies (array), Group
|
||||
|
||||
## Versioning
|
||||
Dependency UUID:
|
||||
`897914ef-5c96-053c-44af-0be823f895fe`
|
||||
|
||||
Version displayed by BG3ModManager should be derived from the release tag.
|
||||
---
|
||||
|
||||
Current behavior:
|
||||
## Guardrails (MUST)
|
||||
|
||||
- `scripts/build.ps1` derives `Version64` from tags like `v0.1.0`
|
||||
- the computed version is written into:
|
||||
- generated `info.json`
|
||||
- staged `meta.lsx` before packaging
|
||||
Before commit:
|
||||
- scope valid (localization/metadata only)
|
||||
- no forbidden content
|
||||
- no build artifacts (`.pak`, `build/`, staging)
|
||||
- no temp/debug artifacts; ignored patterns MUST be present in `.gitignore`: `build/`, `build-stage*`, `.tools/`, `*.pak`
|
||||
- packaging invariants intact
|
||||
- version consistent (if applicable)
|
||||
|
||||
Do not manually hardcode release versions in the committed `meta.lsx` for each release if CI can derive them from tags.
|
||||
Before push:
|
||||
- explicit user approval
|
||||
- commit message valid (RU, factual)
|
||||
|
||||
## info.json Expectations
|
||||
Before release:
|
||||
- version == tag
|
||||
- version bumped if needed
|
||||
- CI/build contract valid
|
||||
- outputs correct (no extra files)
|
||||
|
||||
`info.json` is generated during build and should remain aligned with BG3/BG3ModManager expectations.
|
||||
---
|
||||
|
||||
Current expected shape:
|
||||
## Release & Changelog (MUST)
|
||||
|
||||
- top-level `Mods`
|
||||
- top-level `MD5`
|
||||
- per-mod fields:
|
||||
- `Author`
|
||||
- `Name`
|
||||
- `Folder`
|
||||
- `Version`
|
||||
- `Description`
|
||||
- `UUID`
|
||||
- `Created`
|
||||
- `Dependencies`
|
||||
- `Group`
|
||||
- Every release MUST include changelog.
|
||||
|
||||
Current dependency model:
|
||||
Changelog:
|
||||
- language: Russian
|
||||
- concise, user-facing
|
||||
- describe WHAT changed
|
||||
- group logically
|
||||
|
||||
- `Dependencies` is an array of dependency UUIDs
|
||||
- current dependency UUID:
|
||||
- `897914ef-5c96-053c-44af-0be823f895fe`
|
||||
Sources:
|
||||
- prefer diff over commits
|
||||
|
||||
## CI Trigger Policy
|
||||
Diff rules:
|
||||
- inspect real file changes
|
||||
|
||||
Current workflow policy:
|
||||
Localization (`russian.xml`):
|
||||
- added / changed / removed strings
|
||||
- summarize user-visible impact (UI, spells, descriptions)
|
||||
|
||||
- run on tags `v*`
|
||||
- run on manual dispatch
|
||||
- do not run on every push to `main`
|
||||
Metadata / CI:
|
||||
- describe effect, not raw edits
|
||||
|
||||
## Git / Collaboration Preferences
|
||||
Large diff:
|
||||
- group + summarize
|
||||
|
||||
User preference:
|
||||
If no visible changes:
|
||||
- state "техническое обновление"
|
||||
|
||||
- after making changes, ask for permission before committing
|
||||
- if the user approves, commit and push immediately
|
||||
- for significant changes, propose moving work into a separate branch
|
||||
- feature/fix branches must use the prefix `feat/` or `fix/`
|
||||
- after finishing work in a `feat/` or `fix/` branch, propose merging it back into `main`
|
||||
- comments and commit messages should be written in Russian
|
||||
- commit messages should describe what was done, not what should be done
|
||||
- if changes affect files that go into the final `.pak`, or change the build/release process, propose releasing the next version
|
||||
- if push fails, retry up to two more times with a 3-second pause between attempts
|
||||
Before release:
|
||||
- generate changelog draft
|
||||
- ask for approval
|
||||
|
||||
Do not auto-commit or auto-push without explicit user approval.
|
||||
Approval gates:
|
||||
- Gate A: explicit approval for commit/push (code/content changes)
|
||||
- Gate B: explicit approval for release publish (after changelog draft)
|
||||
|
||||
## Cleanup Expectations
|
||||
Release message:
|
||||
- version
|
||||
- changelog
|
||||
- `[version](url)` if derivable
|
||||
|
||||
Temporary directories and debug artifacts should not remain in the repository.
|
||||
Do not:
|
||||
- invent changes
|
||||
- include internal noise
|
||||
|
||||
Ignored paths currently include:
|
||||
---
|
||||
|
||||
- `build/`
|
||||
- `build-stage*`
|
||||
- `.tools/`
|
||||
- `*.pak`
|
||||
|
||||
If local debugging creates additional temporary folders, remove them when done unless the user explicitly wants to keep them.
|
||||
## Rules Maintenance (MUST)
|
||||
- Changes to `AGENTS.md` / `ACTIONS.md`: prefer compressed, machine-readable edits.
|
||||
- Keep updates minimal and non-duplicative: merge overlapping points, remove redundancy, preserve intent.
|
||||
|
||||
BIN
Mods/DnD 5.5e AIO Russian/GUI/metadata.lsf
Normal file
BIN
Mods/DnD 5.5e AIO Russian/GUI/metadata.lsf
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<save>
|
||||
<version major="4" minor="8" revision="0" build="500"/>
|
||||
<region id="Config">
|
||||
<node id="root">
|
||||
<children>
|
||||
<node id="Dependencies">
|
||||
<children>
|
||||
<node id="ModuleShortDesc">
|
||||
<attribute id="Folder" type="LSString" value=""/>
|
||||
<attribute id="MD5" type="LSString" value=""/>
|
||||
<attribute id="Name" type="LSString" value="DnD 5.5e All-in-One BEYOND"/>
|
||||
<attribute id="PublishHandle" type="uint64" value="0"/>
|
||||
<attribute id="UUID" type="guid" value="897914ef-5c96-053c-44af-0be823f895fe"/>
|
||||
<attribute id="Version64" type="int64" value="36028797018963968"/>
|
||||
</node>
|
||||
</children>
|
||||
<version major="4" minor="8" revision="0" build="500"/>
|
||||
<region id="Config">
|
||||
<node id="root">
|
||||
<children>
|
||||
<node id="Conflicts"/>
|
||||
<node id="Dependencies">
|
||||
<children>
|
||||
<node id="ModuleShortDesc">
|
||||
<attribute id="Folder" type="LSString" value="DnD2024_897914ef-5c96-053c-44af-0be823f895fe"/>
|
||||
<attribute id="MD5" type="LSString" value="4bd42ca93f895d1ec521a286bea09ef2"/>
|
||||
<attribute id="Name" type="LSString" value="DnD 5.5e All-in-One BEYOND"/>
|
||||
<attribute id="PublishHandle" type="uint64" value="4419649"/>
|
||||
<attribute id="UUID" type="guid" value="897914ef-5c96-053c-44af-0be823f895fe"/>
|
||||
<attribute id="Version64" type="int64" value="144396675937468416"/>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
<node id="ModuleInfo">
|
||||
<attribute id="Author" type="LSString" value="Underslumber Team"/>
|
||||
<attribute id="CharacterCreationLevelName" type="FixedString" value=""/>
|
||||
<attribute id="Description" type="LSString" value="Русский перевод мода DnD 5.5e All-in-One BEYOND. Перевод ещё в разработке: AI помогает быстро обновлять тексты, а финальные правки и качество мы проверяем вручную."/>
|
||||
<attribute id="FileSize" type="uint64" value="2488095"/>
|
||||
<attribute id="Folder" type="LSString" value="DnD 5.5e AIO Russian"/>
|
||||
<attribute id="LobbyLevelName" type="FixedString" value=""/>
|
||||
<attribute id="MD5" type="LSString" value="c0a8f3412870277331306e0719fc6f77"/>
|
||||
<attribute id="MenuLevelName" type="FixedString" value=""/>
|
||||
<attribute id="Name" type="LSString" value="DnD 5.5e All-in-One BEYOND Russian Localization"/>
|
||||
<attribute id="NumPlayers" type="uint8" value="4"/>
|
||||
<attribute id="PhotoBooth" type="FixedString" value=""/>
|
||||
<attribute id="PublishHandle" type="uint64" value="5965149"/>
|
||||
<attribute id="StartupLevelName" type="FixedString" value=""/>
|
||||
<attribute id="UUID" type="FixedString" value="6401e84d-daf2-416d-adeb-99c03a2487a6"/>
|
||||
<attribute id="Version64" type="int64" value="281492156579841"/>
|
||||
<children>
|
||||
<node id="PublishVersion">
|
||||
<attribute id="Version64" type="int64" value="281477124194304"/>
|
||||
</node>
|
||||
<node id="Scripts">
|
||||
<children>
|
||||
<node id="Script">
|
||||
<attribute id="UUID" type="FixedString" value="1953f77d-a201-45d7-a194-9b84c34b8461"/>
|
||||
<children>
|
||||
<node id="Parameters">
|
||||
<children>
|
||||
<node id="Parameter">
|
||||
<attribute id="MapKey" type="FixedString" value="HardcoreOnly"/>
|
||||
<attribute id="Type" type="int32" value="1"/>
|
||||
<attribute id="Value" type="LSString" value="0"/>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
<node id="Script">
|
||||
<attribute id="UUID" type="FixedString" value="0d6510f5-50a3-4ecd-83d8-134c9a640324"/>
|
||||
<children>
|
||||
<node id="Parameters">
|
||||
<children>
|
||||
<node id="Parameter">
|
||||
<attribute id="MapKey" type="FixedString" value="HardcoreOnly"/>
|
||||
<attribute id="Type" type="int32" value="1"/>
|
||||
<attribute id="Value" type="LSString" value="0"/>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
<node id="ModuleInfo">
|
||||
<attribute id="Author" type="LSString" value="MikhailRaw"/>
|
||||
<attribute id="CharacterCreationLevelName" type="FixedString" value=""/>
|
||||
<attribute id="Description" type="LSString" value="Русская локализация РјРѕРґР°, который добавляет Рё обновляет контент РІ соответствии СЃ правилами DnD 5.5e Рё РґСЂСѓРіРёРјРё источниками, включая предыстории, классы, таланты, расы, заклинания Рё РјРЅРѕРіРѕРµ РґСЂСѓРіРѕРµ. Рто отдельный РјРѕРґ локализации Рё РѕРЅ требует установленный оригинальный РјРѕРґ."/>
|
||||
<attribute id="FileSize" type="uint64" value="0"/>
|
||||
<attribute id="Folder" type="LSString" value="DnD 5.5e AIO Russian"/>
|
||||
<attribute id="LobbyLevelName" type="FixedString" value=""/>
|
||||
<attribute id="MD5" type="LSString" value=""/>
|
||||
<attribute id="MenuLevelName" type="FixedString" value=""/>
|
||||
<attribute id="Name" type="LSString" value="DnD 5.5e All-in-One BEYOND Russian Localization"/>
|
||||
<attribute id="NumPlayers" type="uint8" value="4"/>
|
||||
<attribute id="PhotoBooth" type="FixedString" value=""/>
|
||||
<attribute id="PublishHandle" type="uint64" value="0"/>
|
||||
<attribute id="StartupLevelName" type="FixedString" value=""/>
|
||||
<attribute id="UUID" type="FixedString" value="6401e84d-daf2-416d-adeb-99c03a2487a6"/>
|
||||
<attribute id="Version64" type="int64" value="36028797018963968"/>
|
||||
<children>
|
||||
<node id="PublishVersion">
|
||||
<attribute id="Version64" type="int64" value="36028797018963968"/>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
</children>
|
||||
</node>
|
||||
</region>
|
||||
</region>
|
||||
</save>
|
||||
|
||||
BIN
Mods/DnD 5.5e AIO Russian/mod_publish_logo.png
Normal file
BIN
Mods/DnD 5.5e AIO Russian/mod_publish_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -1,12 +1,11 @@
|
||||
# DnD 5.5e All-in-One BEYOND Russian Localization
|
||||
|
||||
Русский перевод мода **DnD 5.5e All-in-One BEYOND** для **Baldur's Gate 3**.
|
||||
Русская локализация [**DnD 5.5e All-in-One BEYOND**](https://github.com/Yoonmoonsik/dnd55e) для **Baldur's Gate 3**.
|
||||
|
||||
Оригинальный репозиторий:
|
||||
[Yoonmoonsik/dnd55e](https://github.com/Yoonmoonsik/dnd55e)
|
||||
Публикация на [mod.io](https://mod.io/g/baldursgate3/m/dnd-55e-all-in-one-beyond-russian-localization).
|
||||
|
||||
## О моде
|
||||
|
||||
**DnD 5.5e All-in-One BEYOND** переносит в Baldur's Gate 3 контент и механики, вдохновлённые Dungeons & Dragons 5.5e. Мод расширяет игру новыми и переработанными элементами, включая классы, предыстории, таланты, расы, заклинания и другие связанные системы.
|
||||
Русский перевод мода **DnD 5.5e All-in-One BEYOND**, который переносит в **Baldur's Gate 3** широкий пласт контента и правил **D&D 5.5e / PHB 2024**.
|
||||
|
||||
Этот проект предназначен для русской локализации оригинального мода и его текстового контента.
|
||||
Локализация поддерживается в темпе с апстримом, который сейчас развивается в сторону более полного охвата классов, рас, предысторий, фитов и заклинаний.
|
||||
|
||||
@@ -459,7 +459,7 @@
|
||||
"Locate animals or plants": "Поиск животных или растений",
|
||||
"Locate creature": "Поиск существа",
|
||||
"Locate object": "Поиск предмета",
|
||||
"Long Rest": "долгий отдых",
|
||||
"Long Rest": "Долгий отдых",
|
||||
"Longbow": "Длинный лук",
|
||||
"Longstrider": "Скороход",
|
||||
"Longsword": "Длинный меч",
|
||||
@@ -556,6 +556,10 @@
|
||||
"Origin Feat: Alert": "Черта происхождения: Бдительный",
|
||||
"Origin Feat: Magic Initiate (Cleric)": "Черта происхождения: Посвященный в магию (Жрец)",
|
||||
"Origin Feat: Magic Initiate (Wizard)": "Черта происхождения: Посвященный в магию (Волшебник)",
|
||||
"Origin Feat: Magic Initiate (Druid)": "Черта происхождения: Посвященный в магию (Друид)",
|
||||
"Origin Feat: Healer": "Черта происхождения: Лекарь",
|
||||
"Origin Feat: Lucky": "Черта происхождения: Везунчик",
|
||||
"Origin Feat: Tavern Brawler": "Черта происхождения: Драчун",
|
||||
"Origin Feat: Musician": "Черта происхождения: Музыкант",
|
||||
"Origin Feat: Savage Attacker": "Черта происхождения: Дикий атакующий",
|
||||
"Origin Feat: Skilled": "Черта происхождения: Одаренный",
|
||||
@@ -691,7 +695,7 @@
|
||||
"Shillelagh": "Шиллейла",
|
||||
"Shocking Grasp": "Шоковое прикосновение",
|
||||
"Shocking grasp": "Электрошок",
|
||||
"Short Rest": "короткий отдых",
|
||||
"Short Rest": "Короткий отдых",
|
||||
"Shortbow": "Короткий лук",
|
||||
"Shortsword": "Короткий меч",
|
||||
"Sickle": "Серп",
|
||||
|
||||
210
scripts/apply-translation-edits.ps1
Normal file
210
scripts/apply-translation-edits.ps1
Normal file
@@ -0,0 +1,210 @@
|
||||
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
|
||||
}
|
||||
|
||||
function Assert-UniqueEditContentUid {
|
||||
param(
|
||||
[AllowEmptyCollection()]
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.Generic.HashSet[string]]$Seen,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ContentUid,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Section
|
||||
)
|
||||
|
||||
if (-not $Seen.Add($ContentUid)) {
|
||||
throw "Edits file contains duplicate contentuid '$ContentUid' in '$Section'."
|
||||
}
|
||||
}
|
||||
|
||||
$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
|
||||
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]
|
||||
$seenEditContentUids = [System.Collections.Generic.HashSet[string]]::new()
|
||||
|
||||
$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'."
|
||||
}
|
||||
|
||||
Assert-UniqueEditContentUid -Seen $seenEditContentUids -ContentUid $contentUid -Section "updates"
|
||||
|
||||
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") {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$edit.text)) {
|
||||
throw "Update entry '$contentUid' must contain non-empty 'text' when provided."
|
||||
}
|
||||
$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'."
|
||||
}
|
||||
|
||||
Assert-UniqueEditContentUid -Seen $seenEditContentUids -ContentUid $contentUid -Section "adds"
|
||||
|
||||
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 ', ')"
|
||||
}
|
||||
@@ -7,8 +7,8 @@ param(
|
||||
[string]$ArchiveBaseName = "DnD 5.5e AIO Russian",
|
||||
[string]$ModName = "DnD 5.5e All-in-One BEYOND Russian Localization",
|
||||
[string]$ModUuid = "6401e84d-daf2-416d-adeb-99c03a2487a6",
|
||||
[string]$ModAuthor = "MikhailRaw",
|
||||
[string]$ModDescription = "Russian Localization",
|
||||
[string]$ModAuthor = "Underslumber Team",
|
||||
[string]$ModDescription = "Русская локализация мода, который добавляет и обновляет контент в соответствии с правилами DnD 5.5e и другими источниками, включая предыстории, классы, таланты, расы, заклинания и многое другое. Это отдельный мод локализации и он требует установленный оригинальный мод.",
|
||||
[string]$ModVersion64 = "36028797018963968",
|
||||
[string]$ModGroup = "6401e84d-daf2-416d-adeb-99c03a2487a6",
|
||||
[string]$DependencyUuid = "897914ef-5c96-053c-44af-0be823f895fe",
|
||||
@@ -20,7 +20,8 @@ $ErrorActionPreference = "Stop"
|
||||
function Convert-VersionTagToVersion64 {
|
||||
param(
|
||||
[string]$Tag,
|
||||
[string]$FallbackVersion64
|
||||
[string]$FallbackVersion64,
|
||||
[string]$RepoPath
|
||||
)
|
||||
|
||||
if (-not $Tag) {
|
||||
@@ -32,16 +33,31 @@ function Convert-VersionTagToVersion64 {
|
||||
$normalized = $normalized.Substring(1)
|
||||
}
|
||||
|
||||
if ($normalized -notmatch '^\d+(\.\d+){0,3}$') {
|
||||
return [int64]$FallbackVersion64
|
||||
if ($normalized -notmatch '^(?<base>\d+\.\d+\.\d+)(?:-(?<suffix>[0-9A-Za-z][0-9A-Za-z.-]*))?$') {
|
||||
throw "Version tag '$Tag' is invalid. Expected format: vX.Y.Z or vX.Y.Z-suffix"
|
||||
}
|
||||
|
||||
$parts = $normalized.Split(".")
|
||||
$baseVersion = $Matches.base
|
||||
$suffix = $Matches.suffix
|
||||
$parts = $baseVersion.Split(".")
|
||||
$numbers = @(0, 0, 0, 0)
|
||||
for ($i = 0; $i -lt $parts.Length; $i++) {
|
||||
$numbers[$i] = [int]$parts[$i]
|
||||
}
|
||||
|
||||
if ($suffix) {
|
||||
$resolvedRepoPath = [System.IO.Path]::GetFullPath($RepoPath)
|
||||
$matchingTags = @()
|
||||
|
||||
try {
|
||||
$matchingTags = @(git -C $resolvedRepoPath tag --list "v$baseVersion-*" 2>$null | Where-Object { $_ -and $_ -ne $Tag })
|
||||
} catch {
|
||||
$matchingTags = @()
|
||||
}
|
||||
|
||||
$numbers[3] = $matchingTags.Count + 1
|
||||
}
|
||||
|
||||
return ([int64]$numbers[0] -shl 55) -bor ([int64]$numbers[1] -shl 47) -bor ([int64]$numbers[2] -shl 31) -bor [int64]$numbers[3]
|
||||
}
|
||||
|
||||
@@ -59,7 +75,7 @@ if ($VersionTag) {
|
||||
}
|
||||
$zipPath = Join-Path $buildPath "$archiveName.zip"
|
||||
$infoJsonPath = Join-Path $buildPath "info.json"
|
||||
$resolvedVersion64 = Convert-VersionTagToVersion64 -Tag $VersionTag -FallbackVersion64 $ModVersion64
|
||||
$resolvedVersion64 = Convert-VersionTagToVersion64 -Tag $VersionTag -FallbackVersion64 $ModVersion64 -RepoPath $workspacePath
|
||||
|
||||
if (-not (Test-Path -LiteralPath $DivinePath)) {
|
||||
$resolvedCommand = Get-Command $DivinePath -ErrorAction SilentlyContinue
|
||||
@@ -101,10 +117,10 @@ if (-not (Test-Path -LiteralPath $stagedMetaPath)) {
|
||||
throw "Staged meta.lsx was not found: '$stagedMetaPath'."
|
||||
}
|
||||
|
||||
$stagedMetaContent = Get-Content -LiteralPath $stagedMetaPath -Raw
|
||||
$utf8Encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$stagedMetaContent = [System.IO.File]::ReadAllText($stagedMetaPath, $utf8Encoding)
|
||||
$stagedMetaContent = $stagedMetaContent -replace '(<attribute id="Version64" type="int64" value=")\d+("/>)', "`${1}$resolvedVersion64`${2}"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($stagedMetaPath, $stagedMetaContent, $utf8Bom)
|
||||
[System.IO.File]::WriteAllText($stagedMetaPath, $stagedMetaContent, $utf8Encoding)
|
||||
|
||||
Write-Host "[build.ps1] Staged source tree:"
|
||||
Get-ChildItem -Recurse $stagingPath | Select-Object FullName, Length | Format-Table -AutoSize
|
||||
@@ -113,6 +129,8 @@ if (Test-Path -LiteralPath $tempPackagePath) {
|
||||
Remove-Item -LiteralPath $tempPackagePath -Force
|
||||
}
|
||||
|
||||
# CI quirk: Divine can occasionally emit a broken ~48-byte package for some source roots.
|
||||
# Mitigation: try staged/mods/workspace sources and accept only outputs that look valid by size.
|
||||
$packageAttempts = @(
|
||||
[ordered]@{ Name = "staging-root"; Source = $stagingPath },
|
||||
[ordered]@{ Name = "mods-root"; Source = $modsPath },
|
||||
|
||||
209
scripts/compare-translation.ps1
Normal file
209
scripts/compare-translation.ps1
Normal file
@@ -0,0 +1,209 @@
|
||||
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)) {
|
||||
throw "Localization XML contains a content node without 'contentuid': '$resolvedPath'."
|
||||
}
|
||||
|
||||
if ($entries.ContainsKey($contentUid)) {
|
||||
throw "Localization XML contains duplicate contentuid '$contentUid': '$resolvedPath'."
|
||||
}
|
||||
|
||||
$version = [string]$node.GetAttribute("version")
|
||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||
throw "Localization XML contains contentuid '$contentUid' with empty 'version': '$resolvedPath'."
|
||||
}
|
||||
|
||||
$entries[$contentUid] = [ordered]@{
|
||||
contentuid = $contentUid
|
||||
version = $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
|
||||
|
||||
$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 += "## Agent workflow"
|
||||
$mdLines += "1. Refresh upstream cache: ``scripts/get-upstream-english.ps1``"
|
||||
$mdLines += "2. Refresh diff reports: ``scripts/compare-translation.ps1``"
|
||||
$mdLines += "3. Fill translated texts in ``build/translation-diff/candidates.json``"
|
||||
$mdLines += "4. Apply only prepared edits: ``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"))."
|
||||
113
scripts/send-telegram-notification.ps1
Normal file
113
scripts/send-telegram-notification.ps1
Normal file
@@ -0,0 +1,113 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter()]
|
||||
[string]$BotToken,
|
||||
|
||||
[Parameter()]
|
||||
[string]$ChatId,
|
||||
|
||||
[Parameter()]
|
||||
[string]$ThreadId,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Text,
|
||||
|
||||
[switch]$DisableNotification
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Import-DotEnvFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($line in [System.IO.File]::ReadAllLines($Path)) {
|
||||
$trimmedLine = $line.Trim()
|
||||
if (-not $trimmedLine -or $trimmedLine.StartsWith("#")) {
|
||||
continue
|
||||
}
|
||||
|
||||
$separatorIndex = $trimmedLine.IndexOf("=")
|
||||
if ($separatorIndex -lt 1) {
|
||||
continue
|
||||
}
|
||||
|
||||
$name = $trimmedLine.Substring(0, $separatorIndex).Trim()
|
||||
$value = $trimmedLine.Substring($separatorIndex + 1).Trim()
|
||||
|
||||
if (
|
||||
($value.StartsWith('"') -and $value.EndsWith('"')) -or
|
||||
($value.StartsWith("'") -and $value.EndsWith("'"))
|
||||
) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
|
||||
[System.Environment]::SetEnvironmentVariable($name, $value, "Process")
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-Setting {
|
||||
param(
|
||||
[string]$ExplicitValue,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$EnvName
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExplicitValue)) {
|
||||
return $ExplicitValue
|
||||
}
|
||||
|
||||
$envValue = [System.Environment]::GetEnvironmentVariable($EnvName, "Process")
|
||||
if (-not [string]::IsNullOrWhiteSpace($envValue)) {
|
||||
return $envValue
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
|
||||
$localEnvPath = Join-Path $repoRoot ".env.local"
|
||||
Import-DotEnvFile -Path $localEnvPath
|
||||
|
||||
$BotToken = Resolve-Setting -ExplicitValue $BotToken -EnvName "BOT_TOKEN"
|
||||
$ChatId = Resolve-Setting -ExplicitValue $ChatId -EnvName "TG_CHAT_ID"
|
||||
$ThreadId = Resolve-Setting -ExplicitValue $ThreadId -EnvName "TG_THREAD_ID"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BotToken)) {
|
||||
throw "Telegram bot token is required. Pass -BotToken or set BOT_TOKEN in .env.local."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ChatId)) {
|
||||
throw "Telegram chat id is required. Pass -ChatId or set TG_CHAT_ID in .env.local."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ThreadId)) {
|
||||
throw "Telegram thread id is required. Pass -ThreadId or set TG_THREAD_ID in .env.local."
|
||||
}
|
||||
|
||||
$normalizedText = $Text.
|
||||
Replace("``r``n", "`r`n").
|
||||
Replace("``n", "`n").
|
||||
Replace("%0A", "`n")
|
||||
|
||||
$body = @{
|
||||
chat_id = $ChatId
|
||||
message_thread_id = $ThreadId
|
||||
parse_mode = "HTML"
|
||||
text = $normalizedText
|
||||
}
|
||||
|
||||
if ($DisableNotification) {
|
||||
$body.disable_notification = "true"
|
||||
}
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Method Post `
|
||||
-Uri ("https://api.telegram.org/bot{0}/sendMessage" -f $BotToken) `
|
||||
-Body $body | Out-Null
|
||||
85
scripts/set-version.ps1
Normal file
85
scripts/set-version.ps1
Normal file
@@ -0,0 +1,85 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$VersionTag,
|
||||
[string]$MetaPath = "Mods/DnD 5.5e AIO Russian/meta.lsx",
|
||||
[string]$RepositoryPath = "."
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-ReleaseVersionParts {
|
||||
param(
|
||||
[string]$Tag,
|
||||
[string]$RepoPath
|
||||
)
|
||||
|
||||
$normalized = $Tag
|
||||
if ($normalized.StartsWith("v")) {
|
||||
$normalized = $normalized.Substring(1)
|
||||
}
|
||||
|
||||
if ($normalized -notmatch '^(?<base>\d+\.\d+\.\d+)(?:-(?<suffix>[0-9A-Za-z][0-9A-Za-z.-]*))?$') {
|
||||
throw "Version tag '$Tag' is invalid. Expected format: vX.Y.Z or vX.Y.Z-suffix"
|
||||
}
|
||||
|
||||
$baseVersion = $Matches.base
|
||||
$suffix = $Matches.suffix
|
||||
$parts = $baseVersion.Split(".")
|
||||
$numbers = @(0, 0, 0, 0)
|
||||
for ($i = 0; $i -lt $parts.Length; $i++) {
|
||||
$numbers[$i] = [int]$parts[$i]
|
||||
}
|
||||
|
||||
if ($suffix) {
|
||||
$resolvedRepoPath = [System.IO.Path]::GetFullPath($RepoPath)
|
||||
$matchingTags = @()
|
||||
|
||||
try {
|
||||
$matchingTags = @(git -C $resolvedRepoPath tag --list "v$baseVersion-*" 2>$null | Where-Object { $_ -and $_ -ne $Tag })
|
||||
} catch {
|
||||
$matchingTags = @()
|
||||
}
|
||||
|
||||
$numbers[3] = $matchingTags.Count + 1
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
BaseVersion = $baseVersion
|
||||
Suffix = $suffix
|
||||
Version64 = ([int64]$numbers[0] -shl 55) -bor ([int64]$numbers[1] -shl 47) -bor ([int64]$numbers[2] -shl 31) -bor [int64]$numbers[3]
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedMetaPath = [System.IO.Path]::GetFullPath($MetaPath)
|
||||
if (-not (Test-Path -LiteralPath $resolvedMetaPath)) {
|
||||
throw "meta.lsx was not found: '$resolvedMetaPath'."
|
||||
}
|
||||
|
||||
$releaseVersion = Get-ReleaseVersionParts -Tag $VersionTag -RepoPath $RepositoryPath
|
||||
$resolvedVersion64 = $releaseVersion.Version64
|
||||
$utf8Encoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$metaContent = [System.IO.File]::ReadAllText($resolvedMetaPath, $utf8Encoding)
|
||||
[xml]$metaXml = $metaContent
|
||||
|
||||
# Explicitly target ModuleInfo/Version64 via XML path to avoid touching Dependencies/PublishVersion.
|
||||
$moduleInfoVersionNode = $metaXml.SelectSingleNode('/save/region/node/children/node[@id="ModuleInfo"]/attribute[@id="Version64" and @type="int64"]')
|
||||
if ($null -eq $moduleInfoVersionNode) {
|
||||
throw "ModuleInfo/Version64 attribute was not found in '$resolvedMetaPath'."
|
||||
}
|
||||
|
||||
# Replace only the Version64 attribute that appears inside ModuleInfo before its <children> block.
|
||||
$moduleInfoVersionPattern = '(?s)(<node id="ModuleInfo">\s*(?:(?!<children>).)*?<attribute id="Version64" type="int64" value=")\d+("/>)'
|
||||
if ($metaContent -notmatch $moduleInfoVersionPattern) {
|
||||
throw "ModuleInfo/Version64 attribute was not found in '$resolvedMetaPath'."
|
||||
}
|
||||
|
||||
$updatedMeta = [regex]::Replace(
|
||||
$metaContent,
|
||||
$moduleInfoVersionPattern,
|
||||
"`${1}$resolvedVersion64`${2}",
|
||||
1
|
||||
)
|
||||
|
||||
[System.IO.File]::WriteAllText($resolvedMetaPath, $updatedMeta, $utf8Encoding)
|
||||
|
||||
Write-Host "[set-version.ps1] Updated '$resolvedMetaPath' to Version64=$resolvedVersion64 (from tag '$VersionTag', base '$($releaseVersion.BaseVersion)')."
|
||||
91
scripts/sync-parent-meta.ps1
Normal file
91
scripts/sync-parent-meta.ps1
Normal file
@@ -0,0 +1,91 @@
|
||||
param(
|
||||
[string]$ParentMetaUrl = "https://raw.githubusercontent.com/Yoonmoonsik/dnd55e/main/Mods/DnD2024_897914ef-5c96-053c-44af-0be823f895fe/meta.lsx",
|
||||
[string]$TargetMetaPath = "Mods/DnD 5.5e AIO Russian/meta.lsx"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$resolvedTargetMetaPath = [System.IO.Path]::GetFullPath($TargetMetaPath)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $resolvedTargetMetaPath)) {
|
||||
throw "Target meta.lsx was not found: '$resolvedTargetMetaPath'."
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ParentMetaUrl)) {
|
||||
throw "ParentMetaUrl must not be empty."
|
||||
}
|
||||
|
||||
try {
|
||||
$parentResponse = Invoke-WebRequest -Uri $ParentMetaUrl -UseBasicParsing -TimeoutSec 60
|
||||
} catch {
|
||||
throw "Failed to download parent meta.lsx from '$ParentMetaUrl': $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$parentRaw = $parentResponse.Content
|
||||
$targetRaw = Get-Content -LiteralPath $resolvedTargetMetaPath -Raw
|
||||
|
||||
[xml]$parentXml = $parentRaw
|
||||
[xml]$targetXml = $targetRaw
|
||||
|
||||
$parentModuleInfo = $parentXml.SelectSingleNode('/save/region/node/children/node[@id="ModuleInfo"]')
|
||||
if ($null -eq $parentModuleInfo) {
|
||||
throw "ModuleInfo node was not found in parent meta downloaded from '$ParentMetaUrl'."
|
||||
}
|
||||
|
||||
$requiredFields = @("Folder", "MD5", "Name", "PublishHandle", "UUID", "Version64")
|
||||
$sourceValues = @{}
|
||||
|
||||
foreach ($field in $requiredFields) {
|
||||
$node = $parentModuleInfo.SelectSingleNode("attribute[@id='$field']")
|
||||
if ($null -eq $node) {
|
||||
throw "Required parent ModuleInfo attribute '$field' is missing in meta downloaded from '$ParentMetaUrl'."
|
||||
}
|
||||
|
||||
$value = $node.GetAttribute("value")
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
throw "Required parent ModuleInfo attribute '$field' has empty value in meta downloaded from '$ParentMetaUrl'."
|
||||
}
|
||||
|
||||
$sourceValues[$field] = $value
|
||||
}
|
||||
|
||||
$targetDependencyNode = $targetXml.SelectSingleNode('/save/region/node/children/node[@id="Dependencies"]/children/node[@id="ModuleShortDesc"]')
|
||||
if ($null -eq $targetDependencyNode) {
|
||||
throw "Dependencies/ModuleShortDesc node was not found in target meta: '$resolvedTargetMetaPath'."
|
||||
}
|
||||
|
||||
$changedFields = @()
|
||||
foreach ($field in $requiredFields) {
|
||||
$targetAttr = $targetDependencyNode.SelectSingleNode("attribute[@id='$field']")
|
||||
if ($null -eq $targetAttr) {
|
||||
throw "Target Dependencies/ModuleShortDesc attribute '$field' is missing in '$resolvedTargetMetaPath'."
|
||||
}
|
||||
|
||||
$currentValue = $targetAttr.GetAttribute("value")
|
||||
$newValue = [string]$sourceValues[$field]
|
||||
if ($currentValue -ne $newValue) {
|
||||
$targetAttr.SetAttribute("value", $newValue)
|
||||
$changedFields += $field
|
||||
}
|
||||
}
|
||||
|
||||
if ($changedFields.Count -eq 0) {
|
||||
Write-Host "[sync-parent-meta.ps1] No changes needed. Target dependency data is already up to date."
|
||||
} else {
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $utf8Bom
|
||||
$settings.Indent = $true
|
||||
$settings.IndentChars = " "
|
||||
$settings.NewLineChars = "`n"
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace
|
||||
|
||||
$writer = [System.Xml.XmlWriter]::Create($resolvedTargetMetaPath, $settings)
|
||||
try {
|
||||
$targetXml.WriteTo($writer)
|
||||
} finally {
|
||||
$writer.Dispose()
|
||||
}
|
||||
|
||||
Write-Host ("[sync-parent-meta.ps1] Updated fields: " + ($changedFields -join ", "))
|
||||
}
|
||||
71
scripts/update-translation.ps1
Normal file
71
scripts/update-translation.ps1
Normal file
@@ -0,0 +1,71 @@
|
||||
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
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($EditsPath)) {
|
||||
Write-Host "[update-translation.ps1] Найдены изменения перевода. Подготовьте правки в '$([System.IO.Path]::GetFullPath((Join-Path $resolvedOutputDir "candidates.json")))' и затем запустите повторно с '-EditsPath'."
|
||||
return
|
||||
}
|
||||
|
||||
$effectiveEditsPath = [System.IO.Path]::GetFullPath($EditsPath)
|
||||
if (-not (Test-Path -LiteralPath $effectiveEditsPath)) {
|
||||
throw "Prepared edits file was not found: '$effectiveEditsPath'."
|
||||
}
|
||||
|
||||
& $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