Compare commits

...

215 Commits

Author SHA1 Message Date
teamssUTXO
6155beabb9 ci: update manifest.json
Some checks failed
Generate Manifest / manifest-generation (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
2026-06-12 09:02:14 +00:00
Tim
a5771952f2
Merge pull request #81 from teamssUTXO/main
Update french translations + fix appsettings
2026-06-12 11:01:39 +02:00
Timothé
e15e9f33ae Merge remote-tracking branch 'origin/main' 2026-06-12 10:57:46 +02:00
Timothé
3fb8b20bb2 Update Readme.md 2026-06-12 10:57:16 +02:00
Tim
b1996068d8
Update translations/french.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-12 10:51:15 +02:00
Timothé
b9ff3c64cd Update appsettings to point to the right endpoint 2026-06-12 10:35:52 +02:00
Timothé
a645bfa804 fix : translation dir path + refresh french translations 2026-06-12 10:11:28 +02:00
Tim
4f2307a6ea
Merge pull request #80 from teamssUTXO/main
Some checks failed
Generate Manifest / manifest-generation (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
ci: trigger manifest workflow only on main repository
2026-06-11 11:08:34 +02:00
Tim
d9f4fddc57
Merge branch 'btcpayserver:main' into main 2026-06-11 10:28:17 +02:00
Timothé
9ef8a1a3dd refactor(manifest) : manifest generation only on push in the main repo 2026-06-11 10:23:47 +02:00
DaxSosa
236d565bcc Update manifest.json 2026-06-10 23:17:55 +00:00
Dax Sosa
58986f0569
Merge pull request #78 from btcpayserver/feat/newkeys-no-openrouter
Improve Spanish translation consistency
2026-06-10 17:17:24 -06:00
Dax Sosa
0e64111f3e
Improve Spanish translation consistency 2026-06-10 17:14:45 -06:00
Dax Sosa
61a756b21b
Merge pull request #77 from btcpayserver/feat/newkeys-no-openrouter
Some checks are pending
Generate Manifest / manifest-generation (push) Waiting to run
Tests / unit-tests (push) Waiting to run
Update spanish.json
2026-06-10 10:11:39 -06:00
Dax Sosa
430ad8aa30
Update spanish.json 2026-06-10 10:03:16 -06:00
Tim
4cac73c03f
Merge pull request #75 from teamssUTXO/main
Refactor : Update French string
2026-06-10 09:44:44 +02:00
teamssUTXO
577f9f0620 Update manifest.json 2026-06-10 07:26:48 +00:00
Timothé
32a84338ac Merge remote-tracking branch 'origin/main' 2026-06-10 09:26:09 +02:00
Timothé
7b89ab101a chore : update french.json 2026-06-10 09:25:52 +02:00
teamssUTXO
b75f905c5c Update manifest.json 2026-06-10 07:16:06 +00:00
Timothé
867e9d245a fix : coderabbit comments 2026-06-10 09:15:28 +02:00
teamssUTXO
e1082eb4fe Update manifest.json 2026-06-09 21:59:29 +00:00
Timothé
e91681ad36 fix: isolate per-file errors in RefreshKeysAsync + use Ordinal in all string comparer
Wrap each InsertMissingKeysAsync call in a try/catch so a failure on
one translation file no longer aborts the entire refresh. Errors are
logged and the file is counted as skipped.
2026-06-09 23:58:45 +02:00
Timothé
e8daba5075 chore : fix updated french translations 2026-06-09 21:14:59 +02:00
Timothé
9dfeb71c34 chore : add translation files to solution view 2026-06-09 21:14:42 +02:00
rockstardev
4225700979 Update manifest.json
Some checks are pending
Generate Manifest / manifest-generation (push) Waiting to run
Tests / unit-tests (push) Waiting to run
2026-06-09 15:32:51 +00:00
rockstardev
aa611dc029
Add refresh-keys command and update all language packs (#74)
* Providing option to add new keys without auto translations

* Adding new keys for all languages

* Updating translations for all new keys

* Update translations/italian.json

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update translations/korean.json

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update translations/korean.json

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update translations/romanian.json

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update Translator/Services/TranslationOrchestrator.cs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-09 17:32:06 +02:00
rockstardev
f2dd783fd5
Update Translator/Services/TranslationOrchestrator.cs
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-09 17:28:01 +02:00
rockstardev
bd906cbd1a
Update translations/romanian.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-09 17:21:11 +02:00
rockstardev
6d6d1eb857
Update translations/korean.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-09 17:20:47 +02:00
rockstardev
49ac762760
Update translations/korean.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-09 17:20:32 +02:00
rockstardev
d9839415cd
Update translations/italian.json
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-09 17:20:20 +02:00
rockstardev
0030e6c28d
Updating translations for all new keys 2026-06-09 17:04:18 +02:00
rockstardev
304b9567f0
Adding new keys for all languages 2026-06-09 16:15:35 +02:00
rockstardev
f9bcbb4f84
Providing option to add new keys without auto translations 2026-06-09 16:09:19 +02:00
rockstardev
d1d6bc8780 Update manifest.json 2026-06-09 13:54:42 +00:00
Roxanne
4d8c477dc2
fix(portuguese-brazil): translate 3 high-confidence anglicisms (Checkout/Downgrade/Logs) (#71)
* fix(portuguese-brazil): translate 3 high-confidence anglicisms

From the anti-slop pass requested by @teamssUTXO in -5292199939
2026-05-26. 33 entries in portuguese (brazil).json were flagged as
identical-to-English; most are anglicisms acceptable in modern PT-BR
(Login, Logo, Online, Plugins, Token, Upload, Webhooks, etc).

This PR translates 3 entries where Brazilian Portuguese has clear
conventional translations:

- Checkout -> Finalizar compra
- Downgrade -> Rebaixar
- Logs -> Registros

The other 30 identical-to-English entries are deliberately not
touched - they're judgment calls (Hostname / Timestamp / Pull Payments
/ Memo / Min sats / etc) where the maintainer's preference governs.
Some are widely-accepted anglicisms in BR tech context; some are
specialized BTCPay/Bitcoin protocol terms that should stay English.

Caveat: I'm not a native PT-BR speaker. These three translations are
best-attempts grounded in common Brazilian e-commerce / dev usage.
If thgO-O prefers different phrasings (e.g. 'Checkout' as anglicism,
'Logs' as anglicism), push a maintainer-edit on this branch or
comment with the preferred wording and I'll update.

* fix(portuguese-brazil): revert Checkout + Logs per thgO-O review

Per https://github.com/btcpayserver/btcpayserver-translator/pull/71#pullrequestreview-4365079664:

- Revert 'Checkout' -> keep English. File consistently uses 'checkout'
  in related strings ('página de checkout', 'Aparência do Checkout',
  'Experiência de Checkout'); a translated standalone reads as
  button/action label and creates inconsistency.
- Revert 'Logs' -> keep English. 'log' already naturally used
  ('arquivos de log', 'log do servidor'); 'Registros' is generic
  and may conflict with registration/records meanings.
- Keep 'Downgrade' -> 'Rebaixar' (thgO-O endorsed this catch).

Net PR diff: 1 line, Downgrade only.

* Update Portuguese translations for downgrade message

---------

Co-authored-by: teamssUTXO <teamssUTXO@users.noreply.github.com>
Co-authored-by: r1ckstardev <r1ckstardev@users.noreply.github.com>
Co-authored-by: rockstardev <5191402+rockstardev@users.noreply.github.com>
2026-06-09 15:54:10 +02:00
BSN ∞/21M
a8c4733e2b
26-06-03_Update Translation_de_DE (#73)
* 26-06-03_Update Translation_de_DE

* Update manifest.json

---------

Co-authored-by: bsn21m <bsn21m@users.noreply.github.com>
2026-06-09 15:46:44 +02:00
Tim
202b171cab
Merge pull request #72 from r1ckstardev/feat/anti-slop-html-maintainer-ci
Some checks failed
Tests / unit-tests (push) Has been cancelled
feat(validator): HTML-tag-mismatch + _maintainer shape + wire validate-packs into CI
2026-05-30 01:52:14 +02:00
Timothé
0cb2734570 fix(maintainer field) : whitespace maintainer field will cause the tests to fail 2026-05-30 01:48:19 +02:00
Timothé
5729180fe3 fix(maintainer field) : now CI pass even if a language doesn't have maintainer field 2026-05-30 01:29:00 +02:00
teamssUTXO
3171caefbc ci(tests): wire validate-packs step (continue-on-error initially)
Forgotten in the previous commit on this branch. Step runs the
validate-packs CLI after the unit-tests step with the in-repo
translations/ folder as input. continue-on-error: true keeps the
step report-only until the pre-existing HTML-tag-mismatch issues
in hindi/indonesian/thai are fixed by their maintainers.
2026-05-26 14:13:40 +00:00
teamssUTXO
ce6a8662cb feat(validator): add HTML-tag-mismatch + _maintainer shape rules + wire validate-packs into CI
Anti-slop pass on Phase 4 surfaced 4 gaps in the existing validator
infrastructure (Tim msg 486 in TG -5292199939 2026-05-26). This PR
closes 3 of them; the 4th (broader identical-to-English check beyond
the curated ShortKeyHotspotKeys list) is deliberately left for a
follow-up because expanding the hotspot list requires per-language
maintainer judgment on which anglicisms are acceptable.

## What

### (a) HTML-tag-mismatch rule

`TranslationValidationRules.HasMatchingHtmlTags(source, translation)`
compares the multiset of structural HTML tags between source and
translation. Restricted to a curated allowlist of real markup
elements (strong/em/b/i/code/pre/kbd/a/span/p/div/br/ul/ol/li/h1-6/
table family/abbr/del/ins/q/cite/var/samp/mark/small/sub/sup/u) so
localized example data ('<email@primer.com>', '<John Doe>') doesn't
trip the rule. Catches the same shape my anti-slop pass found on
hindi.json (3 entries) + indonesian.json (1) + thai.json (2):
translation drops a closing </strong> or </code> pair, silently
breaks UI rendering.

No auto-fix path - restoring the English source would lose the
translated prose around the tags. Maintainer needs to re-anchor the
markup by hand.

### (d) _maintainer field validation

`TranslationValidationRules.IsValidMaintainerValue(value)` checks
the field shape ManifestGenerator expects:
`<display name or handle>|<https URL>`. Missing pipe, missing URL,
or non-https URL fail validation. Empty / whitespace value (field
present-but-unset) also fails. LanguagePackValidator skips the
_maintainer entry from the translation-entry pipeline (it's
metadata, not a translation) and runs the shape check separately.

### (c) Wire validate-packs into tests.yml CI

New step in the existing 'unit-tests' job runs
`dotnet run -- validate-packs` after the test step, with
Translation__OutputDirectory pointed at the repo's translations
folder. Step is marked continue-on-error: true initially so the new
HTML-tag rule surfaces the existing hindi/indonesian/thai issues
without blocking unrelated PRs. Once those land follow-up fixes,
drop continue-on-error to gate new slop from landing.

## Tests

5 new tests in LanguagePackValidatorTests:

- ValidateAsync_FlagsHtmlTagMismatch
- ValidateAsync_IgnoresExampleEmailAngleBrackets
- ValidateAsync_FlagsInvalidMaintainerField
- ValidateAsync_AcceptsWellFormedMaintainerField
- ValidateAsync_RejectsMaintainerWithHttpScheme

9/9 LanguagePackValidatorTests passing. Build clean.

## Out of scope

Item (b) - broader identical-to-English check beyond the
ShortKeyHotspotKeys list. The anti-slop pass found ~30 per-language
identical-to-English entries (Checkout, Downgrade, Hostname, etc.);
many are acceptable anglicisms (Login, Logo, Online, Token) and
which to translate is per-language maintainer judgment. Adding a
generic rule risks false-positive churn. Either expand the hotspot
list incrementally as patterns surface, OR add a strict-mode
report-only check in a follow-up.

## Pre-existing issues this surfaces

The validate-packs step (continue-on-error) will report:

- hindi.json: 3 HTML-tag-mismatch entries (Khush + Abhijay007 to fix)
- indonesian.json: 1 HTML-tag-mismatch entry (no maintainer set)
- thai.json: 2 HTML-tag-mismatch entries (no maintainer set)
- romanian.json: 'Text' pre-existing hotspot-key issue

None of these are introduced by this PR; they were latent before.
The CI step makes them visible.
2026-05-26 14:13:18 +00:00
Abhijay007
81c63d8fe0 Update manifest.json
Some checks failed
Generate Manifest / manifest-generation (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
2026-05-21 19:25:48 +00:00
Abhijay Jain
09f36e4855
Merge pull request #68 from Abhijay007/refactor/UpdateRomanian
update romanian
2026-05-22 00:55:24 +05:30
Abhijay Jain
1c9624e050 added new strings
Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
2026-05-22 00:54:10 +05:30
Abhijay Jain
23c943ad42 update romanian
Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
2026-05-09 14:18:02 +05:30
Roxanne
3fdd343776
Merge pull request #56 from teamssUTXO/fix-manifest-generation
Some checks failed
Generate Manifest / manifest-generation (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
Fix : manifest generation
2026-05-07 17:54:25 +00:00
Timothé
e8db389113 fix : .slnx file 2026-05-07 10:34:11 +02:00
Timothé
240f6eff67 add translation folder to slnx 2026-05-07 10:31:39 +02:00
teamssUTXO
bfa9617d89 Update manifest.json 2026-05-05 18:33:25 +00:00
Timothé
03923cf193 Merge branch 'main' of https://github.com/teamssUTXO/btcpayserver-translator 2026-05-05 20:32:41 +02:00
Timothé
6458ed6f3f fix : translation path 2026-05-05 20:32:25 +02:00
teamssUTXO
6587cca3d7 Update manifest.json 2026-05-05 17:52:07 +00:00
Abhijay Jain
fee9b2b317
Merge pull request #31 from Abhijay007/refactor/addRomanianTranslation
Some checks failed
Generate Manifest / manifest-generation (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
refactor: add romanian translation
2026-05-05 17:27:37 +05:30
Nicolas Dorier
2522bc9d9f
Fix download language packs 2026-05-05 12:17:31 +09:00
Nicolas Dorier
8915b1b5db
Fix download language pack 2026-05-05 12:15:31 +09:00
Abhijay007
34149a6ce6 Update manifest.json
Some checks failed
Tests / unit-tests (push) Has been cancelled
Generate Manifest / manifest-generation (push) Has been cancelled
2026-05-01 10:26:47 +00:00
Abhijay Jain
182d6b9539
Merge pull request #53 from Sanja22B/SerbianLatinFixes
Serbian Latin fixes
2026-05-01 15:56:13 +05:30
Sanya
86a2affd2b fix 2026-05-01 12:20:20 +02:00
Abhijay007
6cfd902d5d Update manifest.json 2026-05-01 08:45:20 +00:00
Abhijay Jain
7048ef2df5
Merge pull request #55 from thgO-O/pt-br-translation-review
Improve Brazilian Portuguese translations
2026-05-01 14:14:52 +05:30
thgO.O
652caf67d0
Add Brazilian Portuguese translation maintainer 2026-04-30 22:49:54 -03:00
r1ckstardev
ae9cfa894c Update manifest.json
Some checks are pending
Generate Manifest / manifest-generation (push) Waiting to run
Tests / unit-tests (push) Waiting to run
2026-04-29 21:26:27 +00:00
Roxanne
3d090101ac
Merge pull request #54 from teamssUTXO/main
Add CI to generate manifest.json (closes #52)

Generates manifest.json with BCP47 codes, native names, file names, SHA-256 hashes, maintainer, and SHA-stable updated timestamps. CI workflow at .github/workflows/manifest.yml watches Translator/translations/**.json and re-runs on merge or manual workflow_dispatch.
2026-04-29 21:25:53 +00:00
Timothé
f24efb3ec1 fix : jenny commit 2026-04-29 23:04:42 +02:00
r1ckstardev
ebc247fb55 fix: address review follow-ups on PR #54
Per @j3nnystar_bot review (https://github.com/btcpayserver/btcpayserver-translator/pull/54),
@teamssUTXO triaged 8 findings; this commit ships #1 + #2 + #3 + #5 +
#6 + #7 + #8.

#1 Per-file timestamp drift inside one run.
   ManifestGenerator now captures `runUpdatedAt` once at the top of
   GenerateManifest and threads it into BuildEntry. All files changed
   in a single run share one Updated stamp instead of drifting per
   file as the run progresses.

#2 File-name to language coupling - solution B (explicit mapping).
   Models/LanguageInfo.cs adds an explicit `FileNameToCode` dictionary
   mapping translation-file basenames (`french`, `german`, etc.) to
   Languages keys. New `GetLanguageInfoByFileName` replaces the old
   `GetLanguageInfoByName` Name-equality lookup. The contract is now
   visible: a new translation file requires a row here, which is
   intentional - implicit Name lookup could silently mis-route or
   drop entries (e.g. `portuguese.json` -> "Portuguese (Brazil)" by
   parenthetical accident).

#3 Fail-fast on per-file failure: kept, plus manual recovery trigger.
   .github/workflows/manifest.yml gains `workflow_dispatch:` so a
   broken translation file can be fixed and the manifest re-generated
   from the Actions tab without waiting for another translations push.
   ManifestGenerator's foreach has a comment explicitly naming the
   fail-fast posture and pointing at the dispatch trigger as the
   recovery path.

#5 GetMaintainer async hygiene.
   Swapped sync `File.ReadAllText` for `await File.ReadAllTextAsync`,
   made the helper return `Task<string?>`, awaited at the call site.

#6 Whitespace nit in HashFiles.
   Single space between `await` and `sha256.ComputeHashAsync`.

#7 Default paths anchored to project directory (per @teamssUTXO go-ahead).
   New `ResolveProjectDirectory()` helper walks up from
   `AppContext.BaseDirectory` looking for `BTCPayTranslator.csproj`.
   `--translation-path` defaults to `<project-dir>/translations`,
   `--manifest-path` defaults to `<repo-root>/manifest.json`. The
   command produces the same manifest whether invoked from the repo
   root, from inside Translator/, or from the workflow with its
   explicit `working-directory: Translator`.

#8 Rename `CreateManifest` -> `CreateGenerateManifestCommand`.
   Matches the symmetry of `CreateUpdateCommand`,
   `CreateValidatePacksCommand`, etc.

#4 (existing translation files lacking `_maintainer`) was triaged out
   of scope for this PR per @teamssUTXO.

Local verification: dotnet build clean (0/0 errors, 0 warnings),
dotnet test 40/40 passing on .NET 10 RC.2 (`10.0.100-rc.2.25502.107`).

Co-Authored-By: Timothé <183613235+teamssUTXO@users.noreply.github.com>
2026-04-29 20:20:58 +00:00
Timothé
428bcdcb04 fix : coderabbit suggestion 2026-04-29 21:16:15 +02:00
Timothé
40e9f3e38a test : add manifest generator service tests 2026-04-29 20:47:36 +02:00
Timothé
70c8258f51 fix : datetime update when file don't change 2026-04-29 20:33:14 +02:00
Timothé
c2a34dfd3a fix : change CI working directory 2026-04-29 20:21:21 +02:00
Timothé
e504ca6a14 fix : french translation 2026-04-29 20:17:45 +02:00
Timothé
faee200ad2 feat : add CI to auto-generate manifest 2026-04-29 20:14:17 +02:00
Timothé
510313eda5 feat : add generate manifest cli command 2026-04-29 20:00:29 +02:00
thgO.O
cb75504bf7
Improve Brazilian Portuguese translations 2026-04-29 10:33:50 -03:00
Sanya
884944e42f small change 2026-04-29 08:39:05 +02:00
Timothé
a01e223151 feat : create manifest generator service 2026-04-28 23:57:32 +02:00
Sanya
8795ffa373 fixes 2026-04-28 17:06:21 +02:00
Sanya
3ee7f9d609 small changes 2026-04-28 17:04:24 +02:00
Sanya
e73be9aa37 small adjustment 2026-04-28 15:57:48 +02:00
Sanya
c2474fd589 uniformly changed passphrase translation into dodatna lozinka 2026-04-28 15:54:15 +02:00
Sanya
c62fc071f3 small fixes 2026-04-28 09:35:50 +02:00
Sanya
4ea1955862 small fixes 2026-04-28 09:32:14 +02:00
Sanya
79cf529534 unesete umesto obezbedite 2026-04-28 09:30:40 +02:00
Sanya
390d3fe52b better translations 2026-04-27 21:37:07 +02:00
Tim
45850a03d3
[Feature] Add unit tests & CI workflow (#49)
Some checks failed
Tests / unit-tests (push) Has been cancelled
Co-Authored-By: Timothé <183613235+teamssUTXO@users.noreply.github.com>
Co-authored-by: teamssUTXO <teamssUTXO@users.noreply.github.com>
Co-authored-by: r1ckstardev <r1ckstardev@users.noreply.github.com>
2026-04-25 16:37:14 -05:00
Roxanne
ac18694882
Merge pull request #51 from btcpayserver/roxy/base-translation-hardening
BaseTranslationService: proactive translation-time hardening (extract from #35)

Strict-retry prompting gated on prior-validation-failure only, computed max-tokens with raised upper bound, four-class output validation (including short-key hotspot) gating TranslationResponse success. Stacks on #50 reactive validator. Hermes LGTM.
2026-04-24 19:01:32 +00:00
r1ckstardev
e2949d7198 Add short-key hotspot check to IsValidTranslationOutput (Hermes review blocker)
Hermes review of PR #51 flagged a contamination-class gap: the
generator-side IsValidTranslationOutput wired 3 of 4 checks the
reactive LanguagePackValidator already knows about:
- IsSuspiciousMetaResponse - wired
- HasMatchingPlaceholders - wired
- IsLikelySentenceFallback - wired
- IsShortKeyEnglishFallback - MISSING

That meant short hotspot strings ('Confirm', 'Continue', 'Retry',
'Yes', 'Copy Code', etc) could still round-trip unchanged from
the generator into locale files, only to be caught reactively by
validate-packs later. Defeats the 'proactive' framing of this PR
for one entire contamination class.

Fix: fourth IsShortKeyEnglishFallback(sourceText, translatedText)
check at the end of IsValidTranslationOutput, with a comment
pointing at the symmetry with LanguagePackValidator. Now all four
rules the reactive path catches also block generation-time output.

Hermes non-blocker observations (not addressed in this commit):
- Strict-retry flag only covers the immediately-next attempt; a
  validation-rejection followed by an HTTP/parse/exception failure
  loses strict mode on the final retry. Output validation still
  runs on every successful response so bad text does not land;
  just weaker recovery. Non-blocker per Hermes.
- Prompt rewrite security surface OK (TargetLanguage is from an
  allowlisted SupportedLanguages table, not attacker-controlled).
- Token-cap raise cost profile OK (floor of 220 is cheaper than
  prior hard 400 for short strings; ceiling only exceeded on
  sources >3.6k chars which are rare in UI contexts).

Build clean (0/0) on .NET 10 RC.2.
Co-Authored-By: 1amKhush <khushvendras99@gmail.com>
Reviewed-by: hermes
2026-04-24 18:57:29 +00:00
r1ckstardev
554c8dde7d Address CodeRabbit feedback on #51 (strict-retry trigger + max-tokens ceiling)
Two minor CR comments in Services/BaseTranslationService.cs:

1. Strict-retry prompt was sent after non-LLM failures. Prior code set
   strictMode = attempt > 1 unconditionally, so HTTP errors / HTML-error
   bodies / JSON parse failures / thrown exceptions all triggered the
   'your previous answer was invalid' retry instruction even though no
   LLM answer was ever produced. Added lastFailureWasValidation flag
   tracked across the retry loop - set to true only inside the
   IsValidTranslationOutput rejection branch, reset at the top of each
   iteration. strictMode now fires only after an actual validation
   rejection.

2. 900-token upper cap could truncate verbose target languages (German,
   Hungarian, Finnish, Russian routinely >2x source length). Truncation
   would trip the placeholder check on retry + waste an attempt.
   Raised Math.Clamp bound 900 -> 1800. Made ComputeMaxTokens an
   instance method so it can LogDebug when the clamp altered the
   estimate, per CR's diagnostic-logging suggestion.

Build clean (0/0) on .NET 10 RC.2.
Co-Authored-By: 1amKhush <khushvendras99@gmail.com>
2026-04-24 18:51:16 +00:00
r1ckstardev
16d4748650 Services/BaseTranslationService.cs: proactive translation-time hardening (extract from #35)
Extracted the TranslateAsync hardening from @1amKhush's PR #35 (which
was superseded on the validator side by merged PR #50 + is superseded
on the tests/CI side by @teamssUTXO's open PR #49). Per Uncle 18:26
UTC directive to extract the generation-path improvements that neither
of those PRs cover.

Changes to TranslateAsync:
- maxRetries 2 -> 3; attempts 2+ run in 'strict mode' with an
  explicit 'your previous answer was invalid' retry instruction.
- Prompt now built by BuildSystemPrompt(targetLanguage, strictMode)
  helper (centralizes prompt; easier to evolve without scattering
  string literals). Prompt rewritten to emphasize placeholder
  preservation, prohibit asking for more context, forbid mentioning
  AI/prompt/role - exactly the classes of contamination PR #50's
  validator is designed to catch after the fact.
- max_tokens now ComputeMaxTokens(sourceText) instead of hard 400 -
  bounded [220, 900] with ~2x expansion factor on source length to
  allow longer target-language rendering without runaway cost.
- Post-generation IsValidTranslationOutput() check gates
  TranslationResponse success. Uses PR #50's TranslationValidationRules
  (IsSuspiciousMetaResponse + HasMatchingPlaceholders +
  IsLikelySentenceFallback) directly. Invalid output triggers retry
  (up to maxRetries), then returns empty string on exhaustion.
- Failure paths now return empty string instead of falling back to
  the English source. The source-as-translation fallback was the
  mechanism leaking IsLikelySentenceFallback contamination in the
  first place; closing that path at generation time prevents
  recurrence rather than just detecting it.

Relationship to validator: the existing validate-packs CLI is
reactive (scan and fix existing files). These changes are proactive
(reject bad output at translate-time). Both layers stack.

Trailing whitespace on 3 lines in the inherited patch (from PR #35)
was stripped during extraction; content otherwise unchanged.

Tests: dotnet build clean (0/0), validate-packs still 0/34078 issues
on unchanged locale files (expected - no locale changes here).
Co-Authored-By: 1amKhush <khushvendras99@gmail.com>
2026-04-24 18:28:24 +00:00
Roxanne
d185b544d0
Merge pull request #50 from btcpayserver/roxy/core-validation-rebase
Core language-pack validation + cleanup for issue #34 (rebased from #47)

Supersedes #47. Credit preserved via commit authorship (commits 1-3 by 1amKhush) + Co-Authored-By trailers on follow-up commits. Closes #34.
2026-04-24 17:45:02 +00:00
r1ckstardev
fa1760ec20 Amend Serbian 'Reset' (mixed-case) to 'Resetuj' for consistency
Uncle 17:43 UTC: 'amend Reset key into Resetuj' in Serbian. The
lowercase/title-case 'Reset' key was previously in the global
ShortKeyAllowlist (so the validator did not flag it), but for
Serbian specifically Uncle wants the translation consistent with
'RESET' -> 'RESETUJ' that landed in the prior commit. Serbian now
has 'Reset' -> 'Resetuj' matching the sibling 'Restart' -> 'Restartuj'
imperative pattern already in the file.

Build clean. validate-packs still 0 issues (this key is in the global
allowlist so its state did not change anything validator-side).
Co-Authored-By: 1amKhush <khushvendras99@gmail.com>
2026-04-24 17:44:37 +00:00
r1ckstardev
21a09299c4 Document identical-to-English edge case + translate Serbian RESET
Per Uncle guidance 17:41 UTC: document the 'word legitimately same
in English and target locale' class of false-positive with a code
comment near ShortKeyHotspotKeys, then apply a proper Serbian
translation for RESET instead of introducing allowlist infrastructure.

- Services/TranslationValidationRules.cs: added a note near the
  ShortKeyHotspotKeys definition explaining the legitimate-
  identical-to-English case (loan-words / protocol names adopted
  as-is) and the recommended resolution order: first try to
  translate, only fall back to a per-locale allowlist if a word
  has no localized form. Reverted the LocaleHotspotAllowlist
  dictionary + the optional localeName parameter on
  IsShortKeyEnglishFallback I added in 025e649 - unused in the
  current code after the Serbian fix, removed to avoid carrying
  dead infrastructure.
- Services/LanguagePackValidator.cs: caller reverted to the
  single-arg IsShortKeyEnglishFallback signature.
- translations/serbian.json: 'RESET' -> 'RESETUJ' (imperative,
  matches sibling Serbian 'Restart' -> 'Restartuj' pattern).

Build clean, validate-packs reports 0 issues.
Co-Authored-By: 1amKhush <khushvendras99@gmail.com>
2026-04-24 17:42:31 +00:00
r1ckstardev
025e649694 Revert Serbian RESETUJ + add per-locale allowlist for identical-to-English hotspots
Uncle 17:40 UTC correction: 'for Serbian actually the issue is that
word is same in English and serbian' - my prior commit b41c40f wrongly
translated Serbian 'RESET' to 'RESETUJ'. Reverted serbian.json back
to 'RESET' = 'RESET'.

To keep validate-packs green AND preserve Uncle's correction, added a
per-locale allowlist to the validator:
- TranslationValidationRules: new LocaleHotspotAllowlist dictionary
  keyed by filename-without-extension (lowercase-insensitive) -> set
  of hotspot keys that legitimately stay identical to English in that
  locale. Seeded with 'serbian' -> {'RESET'}.
- IsShortKeyEnglishFallback gains an optional localeName parameter.
  When provided and the (locale, key) pair is in the allowlist,
  returns false instead of flagging.
- LanguagePackValidator now passes Path.GetFileNameWithoutExtension
  so the rule can short-circuit for legitimate loan-words.

Build clean. validate-packs reports 0 issues: Serbian 'RESET' =
'RESET' no longer flagged; all other locales still checked against
the global hotspot set (so if french.json ever has 'RESET' = 'RESET'
it will still be caught, because french is not in the allowlist).

Design note: the allowlist is intentionally narrow (one entry) rather
than opening the door to broad 'translator said it is fine' entries.
Extending it should be a conscious per-locale decision, not a default.
Co-Authored-By: 1amKhush <khushvendras99@gmail.com>
2026-04-24 17:41:29 +00:00
r1ckstardev
b41c40f481 Address Serbian RESET + CodeRabbit comments on PR #50
Serbian (Uncle directive 17:38 UTC following the Spanish fixes):
- translations/serbian.json: 'RESET' was left as English key (flagged
  by validate-packs as Common UI label untranslated). Now 'RESETUJ'.
  Matches sibling locales handling 'RESET' via their own word + the
  Serbian file's imperative style (e.g. 'Restart' -> 'Restartuj').

CodeRabbit actionable comments on PR #50:
- translations/hindi.json:2218: 'Yes' was 'मैं आपकी सहायता कैसे कर सकता/
  सकती हूं?' (LLM assistant prompt 'How can I help you?') - fixed to
  'हाँ' per CR suggestion. Residual contamination from pre-validator
  era that Khush's cleanup missed; good regression case.
- Services/TranslationValidationRules.cs:46-47: German localized-meta
  patterns were the only entries in the array missing
  RegexOptions.IgnoreCase. Added the flag so capitalization variants
  ('Geben Sie...' vs 'geben Sie...') get caught. Matches every other
  language block in the array.
- translations/dutch.json:770: 'Voor een specifiek item van je sjabloon'
  used informal 'je' while the file overwhelmingly uses formal 'u/uw'
  register. Switched to 'uw sjabloon' for consistency.

Verified: dotnet build clean, validate-packs reports 0 issues.

Co-Authored-By: 1amKhush <khushvendras99@gmail.com>
2026-04-24 17:40:11 +00:00
r1ckstardev
45615c6cbb Fix 7 Spanish translation issues flagged by validate-packs
6 empty-string entries had placeholder/token mismatch vs source key
(source has {0} or <b>{0}</b>, Spanish was empty = no placeholders).
Filled in consistent with Dax Sosa style already in the file:
- 'All ({0})' -> 'Todos ({0})' (matches 'All' -> 'Todos')
- 'Subscriber {0} deleted' -> 'Suscriptor {0} eliminado' (matches
  'Dictionary {0} deleted' -> 'Diccionario {0} eliminado')
- 'Subscription dates updated for {0}' -> 'Fechas de suscripción
  actualizadas para {0}'
- 'This action will remove the subscriber <b>{0}</b>.' -> 'Esta
  acción eliminará al suscriptor <b>{0}</b>.'
- 'Unused ({0})' -> 'Sin usar ({0})'
- 'Used ({0})' -> 'Usado ({0})'

1 contaminated entry (LLM meta-response left in place of the
translation): 'More details...' -> 'Más detalles...' (matches
'Details' -> 'Detalles' style). The prior value was 'Por favor
proporcione el texto en inglés que necesita ser traducido.' -
exactly the class of contamination Khush's validator is designed
to flag. Validator caught this one on its own via
IsSuspiciousMetaResponse; useful regression-proof case.

After this commit: dotnet run -- validate-packs reports 0 issues
on the branch. Native Spanish speaker review welcome - translations
follow sibling patterns already in the file but a human gloss is
never a bad idea on BTCPay UI strings.
2026-04-24 17:35:31 +00:00
r1ckstardev
729c701fe8 Converge --fix in a single invocation (follow-up to PR #50 test plan)
--fix previously ran one fix pass then one validate-only pass, so a
single invocation left convergent-but-unresolved issues on the floor:
e.g. 8 -> 2 after one run, 2 -> 0 only after a second invocation.

Now --fix loops ValidateAsync(fix=true) until either the issue count
reaches 0 or a safety bound (10 passes) is hit. Log output still
surfaces per-pass progress for transparency. If the bound is reached
with issues still remaining, a warning is logged pointing at manual
review.

Verified locally: on the pre-fix translation-pack state of this
branch, a single 'validate-packs --fix' now converges in 3 passes
and the follow-up 'validate-packs' reports 0 issues. Behavior for
already-clean input is unchanged (single no-op pass).
2026-04-24 17:23:40 +00:00
r1ckstardev
b9c7f74774 Address PR #47 review feedback
Handled in this pass:
- Program.cs: add explicit IsRequired = false on --fix option (teamss
  T1 - brings --fix in line with other option declarations in the
  file that use the same init-list pattern).
- Services/LanguagePackValidator.cs: extract ApplyFix to a private
  static method returning bool (teamss T2). Side effect on
  fileChanged is now explicit at the call site (fileChanged |=
  ApplyFix(...)), making the flow testable in isolation per teamss'
  note.
- Services/TranslationValidationRules.cs: remove ResolveSentenceFallback
  (teamss T3 - it was a trivial passthrough returning its input and
  had no other callers; inline at use site). Caller in ApplyFix now
  uses the key directly.
- translations/hindi.json: two locale fixes
  - 'remote balance' (was partial-transliteration 'रिमोट संतुलन')
    now pure Hindi 'दूरस्थ शेष' per CodeRabbit, matches sibling
    pattern 'स्थानीय शेष' used on line 44 for 'local balance'.
  - 'Use internal node' (contaminated LLM output starting with
    'HereWrite recommendations Ross candidstra PROVIDED DANGER
    VOLUNTEspecific...' - caught by teamss T4) replaced with
    proper Hindi 'आंतरिक नोड का उपयोग करें', matching the sibling
    pattern 'Use the internal lightning node' already in the file.

Already addressed by Khush's commit 64f8751 (for reference, no
further change needed):
- IsLikelySentenceFallback strips placeholders before tokenizing
  (CR1/Copilot)
- --fix help text already mentions both replace AND remove (CR2/Copilot)
- ApplyFix no longer no-ops on sentence fallbacks - sentenceFallback
  path removes the entry (CR3/CodeRabbit)
- ShortKeyHotspotKeys expanded to include Change Role/Confirm/Edit/
  Update Role/Yes (CR4/CodeRabbit; 'Source' intentionally kept in
  allowlist since many locales reuse the term unchanged)
- hindi.json '{0} Lightning' already translated to '{0} लाइटनिंग'
  (CR7/CodeRabbit)
- turkish.json 'Restart' already translated to 'Yeniden başlat'
  (CR6/Copilot + CR/CodeRabbit)

Italian Email section (CR5/Copilot) verified non-issue after rebase:
main + rebased branch have zero key-diff on translations/italian.json.
The loss Copilot flagged against Khush's original branch is absorbed
by the rebase onto main (which has its own italian.json state).

Co-Authored-By: 1amKhush <khushvendras99@gmail.com>
Co-Authored-By: teamssUTXO <teamssUTXO@users.noreply.github.com>
2026-04-24 17:10:12 +00:00
r1ckstardev
0c019442d9 Drop test suite from validation PR (moved to PR #49)
Per 1amKhush + teamssUTXO agreement on PR #47: test suite moves to
teamssUTXO's own PR (#49) which also adds the CI workflow. This PR
stays focused on the validator command + rules + locale cleanup.

Removed:
- tests/BTCPayTranslator.Tests/BTCPayTranslator.Tests.csproj
- tests/BTCPayTranslator.Tests/LanguagePackValidatorTests.cs
- tests/BTCPayTranslator.Tests/TranslationValidationRulesTests.cs
- BTCPayTranslator.csproj: DefaultItemExcludes + InternalsVisibleTo
  test-project refs (reverted to main's state).

Co-Authored-By: 1amKhush <khushvendras99@gmail.com>
2026-04-24 17:06:59 +00:00
1amKhush
64f8751364 fix: tighten validation heuristics and restore locale integrity
- tighten sentence fallback detection to handle placeholders/HTML without false positives

- align --fix behavior/docs and short-key hotspot coverage

- add and wire validator unit test project with focused rule coverage

- fix confirmed Hindi/Indonesian/Italian/Russian/Turkish translation issues
2026-04-24 17:06:19 +00:00
1amKhush
604b92657a fix: clean contaminated locale entries for issue 34 2026-04-24 17:06:19 +00:00
1amKhush
cf7bcab766 fix: add core language-pack validation command and rules 2026-04-24 17:05:35 +00:00
Abhijay Jain
36b881545b
Merge pull request #45 from Sanja22B/SerbianLatin
Serbian Latin
2026-04-23 17:44:10 +05:30
Sanya
ed0b033747 small change 2026-04-23 13:06:09 +02:00
Sanya
977697d2c8 small fixes 2026-04-23 13:04:05 +02:00
Sanya
217c83cc67 added _maintainer field 2026-04-23 12:47:46 +02:00
Sanya
52a6c4e76a Full phrases should be used as single keys, instead of concatenation of English fragments. Gender agreement has been fixed in this update of Serbian translation 2026-04-19 14:33:11 +02:00
Sanya
7d909bf242 revert change 2026-04-19 13:05:49 +02:00
Sanya
5691cc223c changed script to skript 2026-04-19 13:04:07 +02:00
Sanya
746cb478c7 fixes and change hot/cold to online/offline 2026-04-19 12:55:45 +02:00
Abhijay Jain
e44f909e89
Merge pull request #44 from bsn21m/main
26-04-07_Update Translation_de_De
2026-04-17 14:53:51 +05:30
BSN ∞/21M
53e3f542d7
Merge branch 'btcpayserver:main' into main 2026-04-17 10:25:25 +02:00
rockstardev
35f4242ba0
Merge pull request #43 from btcpayserver/improve/spanish-daxsosa-merge
Improve Spanish: merge Dax Sosa translations, fix AI slop, add metadata
2026-04-16 09:21:20 -05:00
BSN ∞/21M
db10c3d56a
Merge branch 'btcpayserver:main' into main 2026-04-13 19:58:15 +02:00
Sanya
c80663ae87 small fix 2026-04-13 15:53:48 +02:00
Nicolas Dorier
0e607b06d2
Merge pull request #40 from btcpayserver/fix/french-translations
Fix French translations: remove 7 AI slop entries, translate 5 missin…
2026-04-13 19:50:02 +09:00
Nicolas Dorier
f9b449e093
Merge branch 'main' into fix/french-translations 2026-04-13 19:49:42 +09:00
Nicolas Dorier
064b247894
Merge pull request #46 from teamssUTXO/main
Update/Improve French Translations
2026-04-13 19:42:31 +09:00
Timothé
11cd1d0f4f fixes AI suggested 2026-04-13 12:25:53 +02:00
Timothé
cd9932dcfe fix : change abonnement to plan 2026-04-13 10:05:35 +02:00
Timothé
80260109d4 fix : review comments v2 2026-04-12 14:33:23 +02:00
Timothé
9ccdd98908 fix : review comments 2026-04-12 12:35:33 +02:00
Timothé
75acea0ce9 finish trads 2026-04-11 14:57:39 +02:00
Timothé
2ef2a0ce1d trad 2.1k lines 2026-04-10 23:31:52 +02:00
Sanya
1275d1534d plugin u dodatak svuda osim za Plugin server 2026-04-10 14:46:52 +02:00
Sanya
1e3c468d54 fixes 2026-04-09 22:47:17 +02:00
Sanya
7901f56afe hot wallet/cold wallet changed to vruc/hladan novcanik 2026-04-09 22:34:51 +02:00
Sanya
ad110d2b30 small change' 2026-04-09 22:30:19 +02:00
Sanya
f58b149042 finall changes 2026-04-09 22:29:23 +02:00
Timothé
6c5d9c476c trad 2k lines 2026-04-09 19:39:38 +02:00
Sanya
18b4ce58d2 changes 2026-04-09 16:43:49 +02:00
Sanya
edefd209e8 changes 2026-04-09 13:34:27 +02:00
Sanya
734e8bf586 all 2026-04-07 22:48:15 +02:00
Timothé
df1dc02268 trad 1.9k lines 2026-04-07 20:10:24 +02:00
Sanya
e99d9e83cb 300 2026-04-07 17:29:23 +02:00
Sanya
c811372061 799 2026-04-07 16:00:35 +02:00
bsn21m
e09377422f 26-04-07_Update Translation_de_DE 2026-04-07 13:17:06 +02:00
Sanya
b624d4607c 1000 2026-04-07 13:08:39 +02:00
Sanya
5a3218c15f 1300 2026-04-07 12:10:40 +02:00
bsn21m
fb73852a52 26-04-07_Update Fix 2026-04-07 09:45:22 +02:00
Timothé
5d4c876628 add json maintainer key 2026-04-06 20:28:40 +02:00
Sanya
219683ec8c line 1900 2026-04-06 20:23:00 +02:00
Timothé
3550046119 trad plan -> abonnement 2026-04-06 20:08:14 +02:00
r2ckstardev
d74f16bea0 Improve Spanish: merge Dax Sosa translations, fix AI slop, add metadata
Merge higher-quality translations from @daxsosa (DaxSosa/BTCPayServer-Spanish-Translation)
into the canonical key set from btcpayserver master.

Changes:
- 729 keys upgraded to Dax's translations (better capitalization, more natural phrasing)
- 6 AI slop entries fixed (LLM prompt-as-content responses)
- Added _maintainer and _source metadata keys
- All 2,112 canonical keys preserved (98.2% from repo, 35.3% from Dax overlap)
2026-04-06 16:28:57 +00:00
Sanya
70515c324a do 1992 2026-04-06 18:20:35 +02:00
Timothé
3821e2a376 trad : plugin -> extensions 2026-04-06 17:38:01 +02:00
r2ckstardev
d91b5f98c6 Revert CC and LNURL-Withdraw per reviewer feedback
- CC stays as "CC" (universally understood in email context)
- LNURL-Withdraw stays untranslated (protocol name)

Addresses NicolasDorier's review on PR #40.
2026-04-06 12:33:07 +00:00
Timothé
b19d583943 Merge remote-tracking branch 'origin/main' 2026-04-06 13:03:59 +02:00
Timothé
163f1b9f38 trad 1k75 lines 2026-04-06 13:03:45 +02:00
Timothé
a9f5319fdb
Merge branch 'btcpayserver:main' into main 2026-04-06 12:09:46 +02:00
r2ckstardev
ee0793bd87 Translate remaining keys and fix imperative phrasing
- Translate 4 remaining untranslated keys
- Fix imperative phrasing for action buttons
- Preserve user@example.com as-is
2026-04-04 23:55:27 +00:00
Timothé
3e0d2f0f89 trad 1.6k 2026-04-04 23:41:42 +02:00
r2ckstardev
e297cab56a Fix French accents: rôle, Régénérer, à jour
Address CodeRabbit review comments - add missing diacritics:
- Changer le role -> Changer le rôle (circumflex)
- Regenerer -> Régénérer (acute accents)
- Role mis a jour -> Rôle mis à jour (circumflex + grave)
2026-04-04 15:52:57 +00:00
r2ckstardev
89572fab29 Fix French translations: remove 7 AI slop entries, translate 5 missing keys
AI slop removed (7): Change Role, Edit plan, follow these instructions,
Proceed, Regenerate, Role updated, Text - all had LLM responses like
'I'm ready to translate' instead of actual French.

Missing keys translated (5):
- CC -> Copie carbone
- Docs -> Documentation
- Id -> Identifiant
- Plugins -> Extensions
- user@example.com -> utilisateur@exemple.com

61 remaining 'untranslated' keys are French-English cognates where the
translation IS the same word (Actions, Date, Description, Image, etc).
These are correct as-is.
2026-04-04 15:32:20 +00:00
Abhijay Jain
67e16feda2
Merge pull request #39 from btcpayserver/fix/dutch-translations
Fix 27 untranslated strings in Dutch translation
2026-04-04 10:16:17 +05:30
r2ckstardev
944bf9fad7 Fix 23 AI slop entries with proper Dutch translations
Previous translator left meta-responses instead of translations
(e.g. 'Please provide the English text you would like me to translate
to Dutch'). Replaced all 23 with correct Dutch translations.
2026-04-04 03:56:37 +00:00
r2ckstardev
51f3b7564e Address review: revert App Type, Crypto Code, HTML Meta Tags, LND Seed Backup
Per Abhijay's review:
- App Type: keep as-is (same in Dutch, like Account/Code/Filter)
- Crypto Code: keep space (not Cryptocode)
- HTML Meta Tags: remove dash (not HTML-metatags)
- LND Seed Backup: remove dash (not LND Seed-back-up)
2026-04-04 03:45:09 +00:00
r2ckstardev
198bbe751b Fix 27 untranslated strings in Dutch translation
Translated UI labels that had English values identical to their keys.
Applied translations consistent with existing patterns in the file
(e.g. wallet->portemonnee, type->type compound words).

Remaining 57 key==value entries are intentionally English: identical
Dutch words (Account, Code, Filter, Status), brand names (Amazon S3,
Shopify), or technical abbreviations (API, CSV, JSON, URL, LNURL).
2026-04-04 03:01:41 +00:00
Timothé
dae01901e0 trad 1k5 lines 2026-04-03 23:31:04 +02:00
Timothé
9001c0d67a
Merge branch 'btcpayserver:main' into main 2026-03-28 19:41:04 +01:00
Abhijay Jain
693ffa7813
Merge pull request #36 from Abhijay007/fix/defaultURL
fix: update default translations URL
2026-03-28 23:20:39 +05:30
Abhijay Jain
44f33744c3 fix: update default translations URL
Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
2026-03-28 23:19:31 +05:30
Abhijay Jain
b0922ecd2f
Merge pull request #33 from bsn21m/main
26-03-16_Update Translation_de_DE
2026-03-27 17:40:47 +05:30
Timothé
7c22cbf850 trad 1.3k trad 2026-03-24 18:06:50 +01:00
Timothé
547f964b9b trad half 2026-03-21 19:13:57 +01:00
Timothé
013c7c192a trad 1.1klines 2026-03-21 14:53:15 +01:00
Timothé
54273e1ee3 trad 1kline 2026-03-20 11:44:43 +01:00
Timothé
310ec20add trad 900line 2026-03-19 19:59:32 +01:00
Timothé
8e9a7e6b00 trad line800 2026-03-19 18:11:17 +01:00
Timothé
e6ef3abf86 trad : line 800 + label 2026-03-19 14:36:02 +01:00
Timothé
90eca7ff97 trad : line 700 2026-03-17 22:08:13 +01:00
Timothé
39e99422ec trad : 620 lines 2026-03-17 21:42:13 +01:00
bsn21m
231043a5f4 26-03-16_Update Translation_de_DE 2026-03-16 13:53:56 +01:00
Abhijay007
5eeb2c9fbd refactor: add romanian translation
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-03-13 17:19:28 +00:00
Abhijay Jain
c4e4e475ab
Merge pull request #29 from Abhijay007/refactor/updateTranslationsBatch
refactor: updated latest translations of missed languages
2026-03-09 20:07:37 +05:30
Abhijay007
3dea469248 refactor: updated latest translations of missed languages
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-03-09 20:06:56 +05:30
Abhijay Jain
e36de85892
Merge pull request #27 from Abhijay007/refactor/UpdateAllViaUrl
refactor: updated few languages
2026-03-09 19:46:13 +05:30
Abhijay Jain
3a298e1f80
Merge pull request #24 from teamssUTXO/main
Optimized translate prompt + chore
2026-03-09 19:37:37 +05:30
Abhijay007
563a80ca0e refactor: updated few languages
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-03-08 15:28:15 +00:00
Abhijay007
de8ef3cef6 refactor: updated few languages
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-03-08 13:29:52 +00:00
Timothé
3280fbfacd chore : resolve review comments 2026-03-04 17:29:50 +01:00
Timothé
95d6a16963
Merge branch 'btcpayserver:main' into main 2026-03-02 20:14:36 +01:00
Abhijay Jain
d2a91c1125
Merge pull request #23 from Abhijay007/feat/cheatMode
feat: add btcpayserver cheatmode for translations and updated dotnet version
2026-03-02 10:22:42 +05:30
Timothé
4480c5677a chore : update claude model sonnet 3.5 to sonnet 3.6 2026-03-01 20:30:30 +01:00
Timothé
0de33a9384 chore : add .idea to ide files .gitignore 2026-03-01 20:29:59 +01:00
Timothé
d5dfbc845c chore : specify created type 2026-03-01 20:29:31 +01:00
Timothé
fc8de23567 refactor : update claude model & optimaze prompt 2026-03-01 20:28:28 +01:00
Abhijay007
6c29730e34 feat: add btcpayserver cheatmode for translations
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-02-22 18:16:38 +00:00
Abhijay Jain
7660b1e547
Merge pull request #22 from Abhijay007/refactor/updateAlltranslations
refactor: updated translations
2026-02-13 23:24:11 +05:30
Abhijay007
6ded74c79c refactor: updated rest
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-02-13 17:53:18 +00:00
Abhijay007
5a2cb6a22d refactor: updated translations
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-02-13 17:14:29 +00:00
Abhijay Jain
aaf2399060
Merge pull request #21 from Abhijay007/feat/updateAll
feat: add ability to update all translations at once
2026-02-11 17:29:58 +05:30
Abhijay007
ed2b25b331 feat: add ability to update all translations at once
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-02-11 11:58:20 +00:00
Abhijay Jain
5f3feed551
Merge pull request #19 from Abhijay007/feat/BatchUpdate
refactor: updated few translations in batch
2026-02-08 17:00:21 +05:30
Abhijay007
acc8156825 refactor: updated few translations in batch
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-02-06 09:17:30 +00:00
Abhijay Jain
59a6aaa62e
Merge pull request #18 from Abhijay007/feat/updateBatch
feat: add ability to update translations in batches
2026-02-06 14:46:06 +05:30
Abhijay007
9191bdb161 feat: added ability to update translations in batches
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-02-05 17:49:33 +00:00
Abhijay Jain
9bdcb0f009
Merge pull request #16 from Abhijay007/refactor/updateTestTranslation
refactor: updated hindi translations to test update translations fucn
2026-02-04 23:58:40 +05:30
Abhijay007
7b03344c5b refactor : updated hindi translation
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-02-04 18:23:18 +00:00
Abhijay Jain
b72d8744f4
Merge pull request #13 from Abhijay007/feat/updateStrings
feat: added functionality to update translations strings
2026-02-04 21:03:44 +05:30
Abhijay007
6a94a58bbd feat: added functionality to update translations strings
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2026-01-22 16:31:58 +00:00
Nicolas Dorier
ec1505996a
Merge pull request #12 from schjonhaug/refactor/addNorwegianTranslation
feat: add norwegian translation
2026-01-07 16:22:37 +09:00
Andreas Schjønhaug
332bb862d2
feat: add norwegian translation 2025-12-19 10:33:36 +01:00
Abhijay Jain
e98163746b
Merge pull request #9 from btcpayserver/refactor/addIndonesianTranslation
refactor: add indonesian translation
2025-11-16 18:26:06 +05:30
Abhijay007
970e24d9dc refactor: add indonesian translation
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2025-10-24 09:28:52 +00:00
Abhijay Jain
88d4ee35fa
Merge pull request #8 from Abhijay007/refactor/AddThaiTranslation
refactor : add thai translation
2025-10-23 19:35:01 +05:30
Abhijay007
39bde60202 refactor : add thai translation
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2025-10-23 14:03:33 +00:00
Abhijay Jain
0813fdddcb
Merge pull request #7 from Abhijay007/refactor/AddDutchTranslation
refactor: add dutch translation
2025-10-23 18:37:33 +05:30
Abhijay007
6d3cf819c7 refactor: add dutch translation
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2025-10-23 13:05:39 +00:00
Abhijay Jain
c0abca2fc7
Merge pull request #6 from Abhijay007/refactor/addTurkishTranslation
refactor: add turkish translation
2025-10-23 16:52:43 +05:30
Abhijay007
aa648b47b3 refactor: add turkish translation
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2025-10-23 11:18:47 +00:00
Abhijay Jain
9bd4d57b66
Merge pull request #5 from Abhijay007/refactor/AddKoreanTranslation
refactor: add korean translation
2025-10-23 16:14:21 +05:30
Abhijay007
df89e0bc85 refactor: add korean translation
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2025-10-23 10:43:33 +00:00
Abhijay Jain
60d82e9246
Merge pull request #4 from Abhijay007/refactor/AddItalianTranslation
refactor : add italian translation
2025-10-18 00:57:42 +05:30
Abhijay007
eb08e80b6f refactor : add italian translation
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2025-10-17 19:23:36 +00:00
54 changed files with 33385 additions and 3126 deletions

43
.github/workflows/manifest.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Generate Manifest
on:
push:
branches: [ main ]
paths:
- 'translations/**/*.json'
workflow_dispatch:
permissions:
contents: write
jobs:
manifest-generation:
runs-on: ubuntu-latest
if: github.repository == 'btcpayserver/btcpayserver-translator'
env:
CI: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Build solution
run: dotnet build --configuration Release
- name: Generate manifest.json
run: dotnet run -- generate-manifest
working-directory: Translator
- name: Commit
uses: EndBug/add-and-commit@v10
with:
default_author: github_actor
add: ./manifest.json # if the working directory is the repo root dir
message: "ci: update manifest.json"
commit: ""
push: true

44
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
unit-tests:
runs-on: ubuntu-latest
env:
CI: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Build solution
run: dotnet build --configuration Release
- name: Unit Tests
run: dotnet test --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx"
- name: Validate translation packs
continue-on-error: true
env:
Translation__OutputDirectory: ${{ github.workspace }}/translations
run: dotnet run --project Translator/BTCPayTranslator.csproj --configuration Release --no-build -- validate-packs
- name: Upload test logs
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: "**/test-results.trx"

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ obj/
.vscode/
*.user
*.suo
*.idea
# OS generated files
.DS_Store

28
BTCPayTranslator.slnx Normal file
View File

@ -0,0 +1,28 @@
<Solution>
<Folder Name="/Misc/">
<File Path="README.md" />
<File Path=".github/workflows/tests.yml" />
<File Path=".github/workflows/manifest.yml" />
</Folder>
<Folder Name="/translations/">
<File Path="translations/dutch.json" />
<File Path="translations/french.json" />
<File Path="translations/german.json" />
<File Path="translations/hindi.json" />
<File Path="translations/indonesian.json" />
<File Path="translations/italian.json" />
<File Path="translations/japanese.json" />
<File Path="translations/korean.json" />
<File Path="translations/norwegian.json" />
<File Path="translations/portuguese (brazil).json" />
<File Path="translations/romanian.json" />
<File Path="translations/russian.json" />
<File Path="translations/serbian.json" />
<File Path="translations/spanish.json" />
<File Path="translations/thai.json" />
<File Path="translations/turkish.json" />
</Folder>
<Project Path="Translator.Tests/BTCPayTranslator.Tests.csproj" />
<Project Path="Translator/BTCPayTranslator.csproj" />
</Solution>

View File

@ -1,123 +0,0 @@
using System.Collections.Generic;
using System.Linq;
namespace BTCPayTranslator.Models;
public record LanguageInfo(
string Code,
string Name,
string NativeName,
bool IsRightToLeft = false
);
public static class SupportedLanguages
{
public static readonly Dictionary<string, LanguageInfo> Languages = new()
{
["hi"] = new("hi", "Hindi", "हिंदी"),
["es"] = new("es-ES", "Spanish", "Español"),
["fr"] = new("fr-FR", "French", "Français"),
["de"] = new("de-DE", "German", "Deutsch"),
["it"] = new("it-IT", "Italian", "Italiano"),
["pt"] = new("pt-BR", "Portuguese (Brazil)", "Português (Brasil)"),
["ru"] = new("ru-RU", "Russian", "Русский"),
["ja"] = new("ja-JP", "Japanese", "日本語"),
["ko"] = new("ko", "Korean", "한국어"),
["zh-cn"] = new("zh-SG", "Chinese (Simplified)", "简体中文"),
["zh-tw"] = new("zh-TW", "Chinese (Traditional)", "繁體中文"),
["ar"] = new("ar", "Arabic", "العربية", true),
["he"] = new("he", "Hebrew", "עברית", true),
["fa"] = new("fa", "Persian", "فارسی", true),
["tr"] = new("tr", "Turkish", "Türkçe"),
["nl"] = new("nl-NL", "Dutch", "Nederlands"),
["sv"] = new("sv", "Swedish", "Svenska"),
["no"] = new("no", "Norwegian", "Norsk"),
["da"] = new("da-DK", "Danish", "Dansk"),
["fi"] = new("fi-FI", "Finnish", "Suomi"),
["pl"] = new("pl", "Polish", "Polski"),
["cs"] = new("cs-CZ", "Czech", "Čeština"),
["sk"] = new("sk-SK", "Slovak", "Slovenčina"),
["hu"] = new("hu-HU", "Hungarian", "Magyar"),
["ro"] = new("ro", "Romanian", "Română"),
["bg"] = new("bg-BG", "Bulgarian", "Български"),
["hr"] = new("hr-HR", "Croatian", "Hrvatski"),
["sr"] = new("sr", "Serbian", "Српски"),
["sl"] = new("sl-SI", "Slovenian", "Slovenščina"),
["et"] = new("et", "Estonian", "Eesti"),
["lv"] = new("lv", "Latvian", "Latviešu"),
["lt"] = new("lt", "Lithuanian", "Lietuvių"),
["uk"] = new("uk-UA", "Ukrainian", "Українська"),
["be"] = new("be", "Belarusian", "Беларуская"),
["el"] = new("el-GR", "Greek", "Ελληνικά"),
["th"] = new("th-TH", "Thai", "ไทย"),
["vi"] = new("vi-VN", "Vietnamese", "Tiếng Việt"),
["id"] = new("id", "Indonesian", "Bahasa Indonesia"),
["ms"] = new("ms", "Malay", "Bahasa Melayu"),
["tl"] = new("tl", "Filipino", "Filipino"),
["bn"] = new("bn", "Bengali", "বাংলা"),
["ta"] = new("ta", "Tamil", "தமிழ்"),
["te"] = new("te", "Telugu", "తెలుగు"),
["ml"] = new("ml", "Malayalam", "മലയാളം"),
["kn"] = new("kn", "Kannada", "ಕನ್ನಡ"),
["gu"] = new("gu", "Gujarati", "ગુજરાતી"),
["mr"] = new("mr", "Marathi", "मराठी"),
["pa"] = new("pa", "Punjabi", "ਪੰਜਾਬੀ"),
["or"] = new("or", "Odia", "ଓଡ଼ିଆ"),
["as"] = new("as", "Assamese", "অসমীয়া"),
["ur"] = new("ur", "Urdu", "اردو", true),
["ne"] = new("np-NP", "Nepali", "नेपाली"),
["si"] = new("si", "Sinhala", "සිංහල"),
["my"] = new("my", "Myanmar", "မြန်မာ"),
["km"] = new("km", "Khmer", "ខ្មែរ"),
["lo"] = new("lo", "Lao", "ລາວ"),
["ka"] = new("ka", "Georgian", "ქართული"),
["hy"] = new("hy", "Armenian", "Հայերեն"),
["az"] = new("az", "Azerbaijani", "Azərbaycan"),
["kk"] = new("kk-KZ", "Kazakh", "Қазақша"),
["ky"] = new("ky", "Kyrgyz", "Кыргызча"),
["uz"] = new("uz", "Uzbek", "O'zbek"),
["tg"] = new("tg", "Tajik", "Тоҷикӣ"),
["mn"] = new("mn", "Mongolian", "Монгол"),
["am"] = new("am-ET", "Amharic", "አማርኛ"),
["sw"] = new("sw", "Swahili", "Kiswahili"),
["zu"] = new("zu", "Zulu", "isiZulu"),
["af"] = new("af", "Afrikaans", "Afrikaans"),
["is"] = new("is-IS", "Icelandic", "Íslenska"),
["fo"] = new("fo", "Faroese", "Føroyskt"),
["mt"] = new("mt", "Maltese", "Malti"),
["cy"] = new("cy", "Welsh", "Cymraeg"),
["ga"] = new("ga", "Irish", "Gaeilge"),
["gd"] = new("gd", "Scottish Gaelic", "Gàidhlig"),
["eu"] = new("eu", "Basque", "Euskera"),
["ca"] = new("ca-ES", "Catalan", "Català"),
["gl"] = new("gl", "Galician", "Galego"),
["ast"] = new("ast", "Asturian", "Asturianu"),
["br"] = new("br", "Breton", "Brezhoneg"),
["co"] = new("co", "Corsican", "Corsu"),
["sc"] = new("sc", "Sardinian", "Sardu"),
["lb"] = new("lb", "Luxembourgish", "Lëtzebuergesch"),
["rm"] = new("rm", "Romansh", "Rumantsch"),
["fur"] = new("fur", "Friulian", "Furlan"),
["vec"] = new("vec", "Venetian", "Vèneto"),
["nap"] = new("nap", "Neapolitan", "Napulitano"),
["scn"] = new("scn", "Sicilian", "Sicilianu"),
["lmo"] = new("lmo", "Lombard", "Lumbaart"),
["pms"] = new("pms", "Piedmontese", "Piemontèis"),
["lij"] = new("lij", "Ligurian", "Ligure"),
["eml"] = new("eml", "Emilian-Romagnol", "Emiliàn"),
["bs"] = new("bs-BA", "Bosnian", "Bosanski"),
["mk"] = new("mk", "Macedonian", "Македонски"),
["sq"] = new("sq", "Albanian", "Shqip"),
["cnr"] = new("cnr", "Montenegrin", "Crnogorski")
};
public static LanguageInfo? GetLanguageInfo(string code)
{
return Languages.TryGetValue(code, out var info) ? info : null;
}
public static IEnumerable<LanguageInfo> GetAllLanguages()
{
return Languages.Values;
}
}

View File

@ -1,233 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.CommandLine;
using BTCPayTranslator.Models;
using BTCPayTranslator.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using DotNetEnv;
namespace BTCPayTranslator;
class Program
{
static async Task<int> Main(string[] args)
{
// Load .env file if it exists
var envPath = Path.Combine(Directory.GetCurrentDirectory(), ".env");
if (File.Exists(envPath))
{
Env.Load(envPath);
}
// Build configuration
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.AddEnvironmentVariables()
.Build();
// Setup dependency injection
var serviceCollection = new ServiceCollection();
ConfigureServices(serviceCollection, configuration);
var serviceProvider = serviceCollection.BuildServiceProvider();
// Create command line interface
var rootCommand = new RootCommand("BTCPay Server Translation Tool - Translate BTCPay Server to multiple languages using AI")
{
CreateTranslateCommand(serviceProvider),
CreateListLanguagesCommand(),
CreateBatchCommand(serviceProvider),
CreateStatusCommand(serviceProvider)
};
return await rootCommand.InvokeAsync(args);
}
private static void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton(configuration);
services.AddLogging(builder =>
{
builder.AddConsole();
builder.AddConfiguration(configuration.GetSection("Logging"));
});
services.AddHttpClient();
services.AddTransient<TranslationExtractor>();
services.AddTransient<FileWriter>();
services.AddTransient<TranslationOrchestrator>();
// Register Fast OpenRouter translation service
services.AddTransient<ITranslationService, BaseTranslationService>();
}
private static Command CreateTranslateCommand(ServiceProvider serviceProvider)
{
var languageOption = new Option<string>(
"--language",
"Language code to translate to (e.g., 'hi', 'es', 'fr')")
{
IsRequired = true
};
var forceOption = new Option<bool>(
"--force",
"Force retranslation of all strings, even if translations already exist");
var command = new Command("translate", "Translate BTCPay Server to a specific language")
{
languageOption,
forceOption
};
command.SetHandler(async (language, force) =>
{
using var scope = serviceProvider.CreateScope();
var orchestrator = scope.ServiceProvider.GetRequiredService<TranslationOrchestrator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Starting translation for language: {Language}", language);
var success = await orchestrator.TranslateToLanguageAsync(language, force);
if (success)
{
logger.LogInformation("Translation completed successfully!");
Environment.Exit(0);
}
else
{
logger.LogError("Translation failed!");
Environment.Exit(1);
}
}, languageOption, forceOption);
return command;
}
private static Command CreateBatchCommand(ServiceProvider serviceProvider)
{
var languagesOption = new Option<string[]>(
"--languages",
"Multiple language codes to translate to (e.g., 'hi es fr')")
{
IsRequired = true,
AllowMultipleArgumentsPerToken = true
};
var forceOption = new Option<bool>(
"--force",
"Force retranslation of all strings, even if translations already exist");
var continueOnErrorOption = new Option<bool>(
"--continue-on-error",
"Continue processing other languages if one fails")
{
IsRequired = false
};
var command = new Command("batch", "Translate BTCPay Server to multiple languages")
{
languagesOption,
forceOption,
continueOnErrorOption
};
command.SetHandler(async (languages, force, continueOnError) =>
{
using var scope = serviceProvider.CreateScope();
var orchestrator = scope.ServiceProvider.GetRequiredService<TranslationOrchestrator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Starting batch translation for languages: {Languages}",
string.Join(", ", languages));
var results = await orchestrator.TranslateToMultipleLanguagesAsync(languages, force, continueOnError);
var successCount = results.Values.Count(success => success);
var totalCount = results.Count;
logger.LogInformation("Batch translation completed: {SuccessCount}/{TotalCount} successful",
successCount, totalCount);
foreach (var result in results)
{
var status = result.Value ? "✓" : "✗";
logger.LogInformation(" {Status} {Language}", status, result.Key);
}
Environment.Exit(successCount == totalCount ? 0 : 1);
}, languagesOption, forceOption, continueOnErrorOption);
return command;
}
private static Command CreateListLanguagesCommand()
{
var command = new Command("list-languages", "List all supported languages");
command.SetHandler(() =>
{
Console.WriteLine("Supported Languages:");
Console.WriteLine("===================");
foreach (var lang in SupportedLanguages.GetAllLanguages().OrderBy(l => l.Name))
{
Console.WriteLine($"{lang.Code,-10} {lang.Name,-20} {lang.NativeName}");
}
});
return command;
}
private static Command CreateStatusCommand(ServiceProvider serviceProvider)
{
var command = new Command("status", "Show translation status for all languages");
command.SetHandler(async () =>
{
using var scope = serviceProvider.CreateScope();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var fileWriter = scope.ServiceProvider.GetRequiredService<FileWriter>();
var outputDir = configuration["Translation:OutputDirectory"] ??
"translations";
Console.WriteLine("Translation Status:");
Console.WriteLine("==================");
Console.WriteLine($"{"Language",-15} {"Code",-10} {"File Exists",-12} {"Translations",-12}");
Console.WriteLine(new string('-', 55));
foreach (var lang in SupportedLanguages.GetAllLanguages().OrderBy(l => l.Name))
{
var filePath = Path.Combine(outputDir, $"{lang.Name.ToLower()}.json");
var exists = File.Exists(filePath);
var count = 0;
if (exists)
{
try
{
var translations = await fileWriter.LoadExistingBackendTranslationsAsync(filePath);
count = translations.Count;
}
catch
{
// Ignore errors for status check
}
}
var existsText = exists ? "✓" : "✗";
Console.WriteLine($"{lang.Name,-15} {lang.Code,-10} {existsText,-12} {count,-12}");
}
});
return command;
}
}

102
README.md
View File

@ -42,6 +42,13 @@ OPENROUTER_APP_NAME=https://github.com/btcpayserver/btcpayserver
## Usage
**Run from the `Translator/` directory.** All commands below assume your shell's
working directory is `Translator/`:
```bash
cd Translator && dotnet run -- <command>
```
### List Available Languages
```bash
dotnet run -- list-languages
@ -73,6 +80,96 @@ dotnet run -- batch --languages hi es fr de --force
dotnet run -- status
```
### Update Existing Translation with New Strings
#### Update Single Language
```bash
# Update Hindi translation with latest strings from GitHub
dotnet run -- update --language hi
```
#### Update Multiple Languages
```bash
# Update multiple specific languages
dotnet run -- batch-update --languages hi es fr de
# Continue on error (don't stop if one language fails)
dotnet run -- batch-update --languages hi es fr de --continue-on-error
```
#### Update All Existing Languages Automatically
```bash
# Automatically detect and update all translation files
dotnet run -- update-all
# Continue on error
dotnet run -- update-all --continue-on-error
```
**How Update Commands Work:**
- Fetches the latest strings from BTCPayServer's GitHub repository (once for all languages in `update-all`)
- Compares with your local translation file(s)
- **Only translates new strings** that were added (e.g., if you have 2000 strings and GitHub has 2015, only 15 new strings are translated)
- Removes strings that were deleted from the source
- Preserves all existing translations
- Maintains the same order as the source file
**When to Use:**
- `update` - Update a single language
- `batch-update` - Update specific languages you choose
- `update-all` - Update all translation files in your translations directory automatically (most convenient!)
This is useful when BTCPayServer adds new features and strings. Instead of retranslating everything, you can just update with the new additions.
### Refresh Keys Without Translating (placeholders)
If you just want to add the newly-added English keys to your translation files as placeholders (to translate later, by hand or with `update`), use `refresh-keys`. Unlike `update`, it does **not** call the AI service and does **not** require an OpenRouter API key.
```bash
# Refresh all translation files from a LOCAL source file (no download)
dotnet run -- refresh-keys --source-file ../btcpayserver/BTCPayServer/Services/Translations.Default.cs
# Refresh only specific languages
dotnet run -- refresh-keys --source-file ./Translations.Default.cs --languages fr es de
# Refresh from a running BTCPay Server (includes the DI-registered strings)
dotnet run -- refresh-keys --btcpay-url http://localhost:14142
# Without --source-file / --btcpay-url it falls back to the configured InputFile (GitHub)
dotnet run -- refresh-keys
```
**How `refresh-keys` differs from `update`:**
- **No AI / no API key** - new keys are inserted with the English text as a placeholder value.
- **Insert-only** - it never removes keys (so DI-registered strings not present in the static source are kept). It only adds keys that are missing.
- **Byte-preserving** - existing entries (including `_maintainer`/`_source` metadata, ordering, and formatting) are left untouched; only new lines are added. Re-running it is a no-op once everything is present.
Options: `--source-file <path>` (local file, overrides the configured InputFile), `--btcpay-url <url>` (takes precedence over `--source-file`), `--languages <codes>` (optional filter; omit to refresh all files).
## Fetching Translations from a Running BTCPay Server
By default the tool fetches strings by parsing `Translations.Default.cs` from GitHub. However, some strings are registered via Dependency Injection (by plugins, payment methods, etc.) and do not appear in that file.
When BTCPay Server is running in debug/cheat mode, it exposes a `GET /cheat/translations/default-en` endpoint that returns the complete set of all registered English strings. Pass `--btcpay-url` to any command to use it instead:
```bash
# 1. Start BTCPay Server in debug mode (cheatmode is enabled automatically)
cd path/to/btcpayserver/BTCPayServer
dotnet run --launch-profile Bitcoin
# 2. Run any translation command against the live instance
dotnet run -- update-all --btcpay-url http://localhost:14142
dotnet run -- translate --language ja --btcpay-url http://localhost:14142
```
You can also set it permanently in `.env` so you don't have to pass it every time:
```bash
TRANSLATION_BTCPAY_URL=http://localhost:14142
```
All commands work without `--btcpay-url` — it is purely optional. The only difference is that without it, the ~100 DI-registered strings are not included.
## Supported Languages
The tool supports 100+ languages including:
@ -93,6 +190,7 @@ The tool supports 100+ languages including:
| `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | OpenRouter API base URL |
| `OPENROUTER_SITE_NAME` | `BTCPayTranslator` | Site name for analytics |
| `OPENROUTER_APP_NAME` | `https://github.com/btcpayserver/btcpayserver` | App name for analytics |
| `TRANSLATION_BTCPAY_URL` | _(empty)_ | BTCPay Server base URL for fetching all strings in debug mode |
### Application Settings (appsettings.json)
@ -103,7 +201,8 @@ The tool supports 100+ languages including:
"MaxRetries": 3,
"DelayBetweenRequests": 1000,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/Services/Translations.Default.cs",
"OutputDirectory": "translations"
"OutputDirectory": "../translations",
"BTCPayUrl": ""
}
}
```
@ -134,4 +233,3 @@ Each translation file includes:
## Help us make it better
All the translations are AI generated and AI can make mistakes sometimes, so if you recognize a string that might need to be edited, share a pull request.

View File

@ -1,188 +0,0 @@
using System.Threading;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Text.Json;
using BTCPayTranslator.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayTranslator.Services;
public class FileWriter
{
private readonly ILogger<FileWriter> _logger;
private readonly JsonSerializerSettings _jsonSettings;
public FileWriter(ILogger<FileWriter> logger)
{
_logger = logger;
_jsonSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented,
StringEscapeHandling = StringEscapeHandling.EscapeNonAscii
};
}
public async Task WriteCheckoutTranslationFileAsync(
string outputPath,
LanguageInfo languageInfo,
Dictionary<string, string> translations)
{
try
{
// Create the translation file structure
var translationFile = new JObject
{
["NOTICE_WARN"] = "THIS CODE HAS BEEN AUTOMATICALLY GENERATED FROM TRANSIFEX, IF YOU WISH TO HELP TRANSLATION COME ON THE SLACK https://chat.btcpayserver.org/ TO REQUEST PERMISSION TO https://www.transifex.com/btcpayserver/btcpayserver/",
["code"] = languageInfo.Code,
["currentLanguage"] = languageInfo.NativeName
};
// Add all translations
foreach (var translation in translations.OrderBy(t => t.Key))
{
translationFile[translation.Key] = translation.Value;
}
// Ensure output directory exists
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
_logger.LogInformation("Created directory: {Directory}", directory);
}
// Write the file
var json = translationFile.ToString(Formatting.Indented);
await File.WriteAllTextAsync(outputPath, json);
_logger.LogInformation("Successfully wrote {Count} translations to {OutputPath}",
translations.Count, outputPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error writing translation file to {OutputPath}", outputPath);
throw;
}
}
public async Task WriteBackendTranslationFileAsync(
string outputPath,
LanguageInfo languageInfo,
Dictionary<string, string> translations)
{
try
{
// Create the backend translation file structure (simple JSON)
var translationFile = new JObject();
// Add all translations
foreach (var translation in translations.OrderBy(t => t.Key))
{
translationFile[translation.Key] = translation.Value;
}
// Ensure output directory exists
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
_logger.LogInformation("Created directory: {Directory}", directory);
}
// Write the file
var json = translationFile.ToString(Formatting.Indented);
await File.WriteAllTextAsync(outputPath, json);
_logger.LogInformation("Successfully wrote {Count} backend translations to {OutputPath}",
translations.Count, outputPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error writing backend translation file to {OutputPath}", outputPath);
throw;
}
}
public async Task<Dictionary<string, string>> LoadExistingBackendTranslationsAsync(string filePath)
{
try
{
if (!File.Exists(filePath))
{
return new Dictionary<string, string>();
}
var content = await File.ReadAllTextAsync(filePath);
var jsonObject = JObject.Parse(content);
var translations = new Dictionary<string, string>();
foreach (var property in jsonObject.Properties())
{
var value = property.Value?.ToString() ?? "";
if (!string.IsNullOrEmpty(value))
{
translations[property.Name] = value;
}
}
_logger.LogInformation("Loaded {Count} existing translations from {FilePath}",
translations.Count, filePath);
return translations;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading existing translations from {FilePath}", filePath);
return new Dictionary<string, string>();
}
}
public async Task WriteSummaryReportAsync(
string outputPath,
string language,
BatchTranslationResponse response,
Dictionary<string, string> finalTranslations)
{
try
{
var report = new
{
Language = language,
Timestamp = DateTime.UtcNow,
Translation = new
{
TotalItems = response.Results.Count,
SuccessfulTranslations = response.SuccessCount,
FailedTranslations = response.FailureCount,
Duration = response.Duration.ToString(@"hh\:mm\:ss"),
SuccessRate = $"{(double)response.SuccessCount / response.Results.Count * 100:F1}%"
},
Output = new
{
FinalTranslationCount = finalTranslations.Count,
OutputFile = outputPath
},
Failures = response.Results
.Where(r => !r.Success)
.Select(r => new { r.Key, r.Error })
.ToArray()
};
var reportPath = Path.ChangeExtension(outputPath, ".report.json");
var json = JsonConvert.SerializeObject(report, _jsonSettings);
await File.WriteAllTextAsync(reportPath, json);
_logger.LogInformation("Translation summary report written to {ReportPath}", reportPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error writing summary report");
}
}
}

View File

@ -1,184 +0,0 @@
using System.Threading;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using BTCPayTranslator.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace BTCPayTranslator.Services;
public class TranslationOrchestrator
{
private readonly ITranslationService _translationService;
private readonly TranslationExtractor _extractor;
private readonly FileWriter _fileWriter;
private readonly IConfiguration _configuration;
private readonly ILogger<TranslationOrchestrator> _logger;
public TranslationOrchestrator(
ITranslationService translationService,
TranslationExtractor extractor,
FileWriter fileWriter,
IConfiguration configuration,
ILogger<TranslationOrchestrator> logger)
{
_translationService = translationService;
_extractor = extractor;
_fileWriter = fileWriter;
_configuration = configuration;
_logger = logger;
}
public async Task<bool> TranslateToLanguageAsync(string languageCode, bool forceRetranslate = false)
{
try
{
var languageInfo = SupportedLanguages.GetLanguageInfo(languageCode);
if (languageInfo == null)
{
_logger.LogError("Unsupported language code: {LanguageCode}", languageCode);
return false;
}
_logger.LogInformation("Starting translation to {Language} ({NativeName})",
languageInfo.Name, languageInfo.NativeName);
// Extract source translations from Default.cs
var inputFile = _configuration["Translation:InputFile"] ??
"../BTCPayServer/Services/Translations.Default.cs";
var sourceTranslations = await _extractor.ExtractFromDefaultFileAsync(inputFile);
// Determine output paths
var outputDir = _configuration["Translation:OutputDirectory"] ??
"../BTCPayServer/translations";
var outputPath = Path.Combine(outputDir, $"{languageInfo.Name.ToLower()}.json");
// Load existing translations if they exist
var existingTranslations = await _fileWriter.LoadExistingBackendTranslationsAsync(outputPath);
// Determine what needs to be translated
Dictionary<string, string> translationsToProcess;
if (forceRetranslate)
{
translationsToProcess = sourceTranslations;
_logger.LogInformation("Force retranslate mode: processing all {Count} translations",
sourceTranslations.Count);
}
else
{
translationsToProcess = _extractor.GetTranslationsToUpdate(sourceTranslations, existingTranslations);
if (translationsToProcess.Count == 0)
{
_logger.LogInformation("No new translations needed for {Language}", languageInfo.Name);
return true;
}
}
// Prepare translation requests for ALL translations
var batchSize = _configuration.GetValue<int>("Translation:BatchSize", 50);
var requests = translationsToProcess
.Select(t => new TranslationRequest(t.Key, t.Value, languageInfo.Name))
.ToList();
// Process translations in batches
var allResults = new List<TranslationResponse>();
for (int i = 0; i < requests.Count; i += batchSize)
{
var batch = requests.Skip(i).Take(batchSize).ToList();
_logger.LogInformation("Processing batch {CurrentBatch}/{TotalBatches} ({Count} items)",
(i / batchSize) + 1, (int)Math.Ceiling((double)requests.Count / batchSize), batch.Count);
var batchRequest = new BatchTranslationRequest(batch, languageInfo.Name, languageInfo.NativeName);
var batchResponse = await _translationService.TranslateBatchAsync(batchRequest);
allResults.AddRange(batchResponse.Results);
// Add delay between batches to be respectful to the API
if (i + batchSize < requests.Count)
{
var delay = _configuration.GetValue<int>("Translation:DelayBetweenRequests", 1000);
await Task.Delay(delay);
}
}
// Process results
var newTranslations = allResults
.Where(r => r.Success)
.ToDictionary(r => r.Key, r => r.TranslatedText);
var finalTranslations = _extractor.MergeTranslations(existingTranslations, newTranslations);
// Write backend translation file (simple JSON format)
await _fileWriter.WriteBackendTranslationFileAsync(
outputPath, languageInfo, finalTranslations);
// Write summary report
var summaryResponse = new BatchTranslationResponse(
allResults,
allResults.Count(r => r.Success),
allResults.Count(r => !r.Success),
TimeSpan.Zero);
await _fileWriter.WriteSummaryReportAsync(
outputPath, languageInfo.Name, summaryResponse, finalTranslations);
var successRate = (double)newTranslations.Count / translationsToProcess.Count * 100;
_logger.LogInformation(
"Translation completed for {Language}: {SuccessCount}/{TotalCount} successful ({SuccessRate:F1}%)",
languageInfo.Name, newTranslations.Count, translationsToProcess.Count, successRate);
return successRate > 80; // Consider successful if >80% success rate
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during translation process for language {LanguageCode}", languageCode);
return false;
}
}
public async Task<Dictionary<string, bool>> TranslateToMultipleLanguagesAsync(
IEnumerable<string> languageCodes,
bool forceRetranslate = false,
bool continueOnError = true)
{
var results = new Dictionary<string, bool>();
foreach (var languageCode in languageCodes)
{
try
{
_logger.LogInformation("Starting translation for language: {LanguageCode}", languageCode);
var success = await TranslateToLanguageAsync(languageCode, forceRetranslate);
results[languageCode] = success;
if (!success && !continueOnError)
{
_logger.LogWarning("Translation failed for {LanguageCode}, stopping batch process", languageCode);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error translating language {LanguageCode}", languageCode);
results[languageCode] = false;
if (!continueOnError)
{
break;
}
}
}
var totalLanguages = results.Count;
var successfulLanguages = results.Values.Count(success => success);
_logger.LogInformation("Batch translation completed: {SuccessCount}/{TotalCount} languages successful",
successfulLanguages, totalLanguages);
return results;
}
}

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>BTCPayTranslator.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.0.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Translator\BTCPayTranslator.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,213 @@
using Xunit;
namespace BTCPayTranslator.Tests.CLI;
public class CliTests
{
[Fact]
public async Task ListLanguages_ReturnsZero_AndPrintsKnownLanguage()
{
var result = await CliTestHost.RunAsync(["list-languages"]);
Assert.Equal(0, result.ExitCode);
Assert.Contains("Supported Languages", result.CombinedOutput);
Assert.Contains("fr-FR", result.CombinedOutput);
Assert.Contains("bs-BA", result.CombinedOutput);
}
[Fact]
public async Task Translate_WithUnsupportedLanguage_ReturnsNonZero()
{
var result = await CliTestHost.RunAsync(["translate", "--language", "xx" ]);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Unsupported language code", result.CombinedOutput);
}
[Fact]
public async Task Update_WhenTranslationFileMissing_ReturnsNonZero()
{
var outputDirectory = CreateTempDirectory();
var inputFile = CreateKnownTranslationsInputFile();
try
{
var result = await CliTestHost.RunAsync(
["update", "--language", "fr"],
new Dictionary<string, string?>
{
["Translation__OutputDirectory"] = outputDirectory,
["Translation__InputFile"] = inputFile
});
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Translation file not found", result.CombinedOutput);
}
finally
{
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, recursive: true);
}
if (File.Exists(inputFile))
{
File.Delete(inputFile);
}
}
}
[Fact]
public async Task UpdateAll_WhenNoTranslationFilesFound_ReturnsNonZero()
{
var outputDirectory = CreateTempDirectory();
try
{
var result = await CliTestHost.RunAsync(
["update-all"],
new Dictionary<string, string?>
{
["Translation__OutputDirectory"] = outputDirectory
});
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("No translation files found", result.CombinedOutput);
}
finally
{
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, recursive: true);
}
}
}
[Fact]
public async Task BatchUpdate_WithContinueOnError_ProcessesMultipleLanguages_AndReturnsNonZero()
{
var outputDirectory = CreateTempDirectory();
try
{
var result = await CliTestHost.RunAsync(
["batch-update", "--languages", "fr", "xx", "--continue-on-error"],
new Dictionary<string, string?>
{
["Translation__OutputDirectory"] = outputDirectory
});
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Unsupported language code: xx", result.CombinedOutput);
Assert.Matches(@"Batch update completed: \d+/2", result.CombinedOutput);
}
finally
{
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, recursive: true);
}
}
}
[Fact]
public async Task ValidatePacks_WithSuspiciousEntries_ReturnsNonZero()
{
var outputDirectory = CreateTempDirectory();
var translationFile = Path.Combine(outputDirectory, "french.json");
try
{
await File.WriteAllTextAsync(translationFile, """
{
"hello": "bonjour",
"prompt": "please provide the english text"
}
""");
var result = await CliTestHost.RunAsync(
["validate-packs"],
new Dictionary<string, string?>
{
["Translation__OutputDirectory"] = outputDirectory
});
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Validation completed", result.CombinedOutput);
Assert.Contains("Suspicious LLM/meta-response content", result.CombinedOutput);
}
finally
{
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, recursive: true);
}
}
}
[Fact]
public async Task RefreshKeys_InsertsMissingKeys_FromLocalSourceFile_AndReturnsZero()
{
var outputDirectory = CreateTempDirectory();
var inputFile = CreateKnownTranslationsInputFile();
var frenchFile = Path.Combine(outputDirectory, "french.json");
try
{
await File.WriteAllTextAsync(frenchFile, "{\r\n \"existing\": \"valeur\"\r\n}");
var result = await CliTestHost.RunAsync(
["refresh-keys", "--source-file", inputFile],
new Dictionary<string, string?>
{
["Translation__OutputDirectory"] = outputDirectory,
["OPENROUTER_API_KEY"] = "" // refresh-keys must work without OpenRouter configured
});
Assert.Equal(0, result.ExitCode);
Assert.Contains("Refresh completed", result.CombinedOutput);
var written = await File.ReadAllTextAsync(frenchFile);
Assert.Contains("\"hello\"", written); // new source key inserted
Assert.Contains("\"existing\": \"valeur\"", written); // existing entry untouched
}
finally
{
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, recursive: true);
}
if (File.Exists(inputFile))
{
File.Delete(inputFile);
}
}
}
private static string CreateTempDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.CliTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
private static string CreateKnownTranslationsInputFile()
{
var path = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.CliTests", Guid.NewGuid().ToString("N") + ".cs");
var parent = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(parent);
var content = "public class Seed\n" +
"{\n" +
" public void Load()\n" +
" {\n" +
" var knownTranslations = \"\"\"\n" +
"{\n" +
" \"hello\": \"Hello\"\n" +
"}\n" +
"\"\"\";\n" +
" }\n" +
"}\n";
File.WriteAllText(path, content);
return path;
}
}

View File

@ -0,0 +1,99 @@
using System.Diagnostics;
namespace BTCPayTranslator.Tests;
internal static class CliTestHost
{
public static async Task<CliResult> RunAsync(
IReadOnlyList<string> args,
IDictionary<string, string?>? environmentVariables = null,
int timeoutMilliseconds = 60000)
{
var projectDirectory = ResolveTranslatorProjectDirectory();
// Build & Run the Solution
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
WorkingDirectory = projectDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("run");
startInfo.ArgumentList.Add("--project");
startInfo.ArgumentList.Add(projectDirectory);
startInfo.ArgumentList.Add("--");
foreach (var arg in args)
startInfo.ArgumentList.Add(arg);
startInfo.Environment["OPENROUTER_API_KEY"] = "test-key";
startInfo.Environment["OPENROUTER_MODEL"] = "test-model";
if (environmentVariables != null)
{
foreach (var (key, value) in environmentVariables)
{
startInfo.Environment[key] = value;
}
}
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
var stdOutTask = process.StandardOutput.ReadToEndAsync();
var stdErrTask = process.StandardError.ReadToEndAsync();
using var cts = new CancellationTokenSource(timeoutMilliseconds);
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException) { /* already exited */ }
catch (System.ComponentModel.Win32Exception) { /* exiting / access denied */ }
await Task.WhenAny(Task.WhenAll(stdOutTask, stdErrTask), Task.Delay(2000));
var partialOut = stdOutTask.IsCompletedSuccessfully ? stdOutTask.Result : string.Empty;
var partialErr = stdErrTask.IsCompletedSuccessfully ? stdErrTask.Result : string.Empty;
throw new TimeoutException(
$"CLI did not exit within {timeoutMilliseconds} ms.\nStdOut: {partialOut}\nStdErr: {partialErr}");
}
var stdOut = await stdOutTask;
var stdErr = await stdErrTask;
return new CliResult(process.ExitCode, stdOut, stdErr);
}
private static string ResolveTranslatorProjectDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null)
{
var candidate = Path.Combine(directory.FullName, "Translator", "BTCPayTranslator.csproj");
if (File.Exists(candidate))
{
return Path.GetDirectoryName(candidate)!;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Could not locate Translator project directory.");
}
}
internal sealed record CliResult(int ExitCode, string StdOut, string StdErr)
{
public string CombinedOutput => StdOut + Environment.NewLine + StdErr;
}

View File

@ -0,0 +1,37 @@
using BTCPayTranslator.Models;
using BTCPayTranslator.Services;
namespace BTCPayTranslator.Tests;
internal sealed class FakeTranslationService : ITranslationService
{
private readonly Func<TranslationRequest, TranslationResponse> _translate;
public FakeTranslationService(Func<TranslationRequest, TranslationResponse>? translate = null)
{
_translate = translate ?? (r => new TranslationResponse(r.Key, $"translated-{r.Key}", true));
}
public string ProviderName => "Fake";
public List<TranslationRequest> SeenRequests { get; } = new();
public Task<TranslationResponse> TranslateAsync(TranslationRequest request)
{
SeenRequests.Add(request);
return Task.FromResult(_translate(request));
}
public Task<BatchTranslationResponse> TranslateBatchAsync(BatchTranslationRequest request)
{
SeenRequests.AddRange(request.Items);
var results = request.Items
.Select(_translate)
.ToList();
var successCount = results.Count(r => r.Success);
var failureCount = results.Count - successCount;
return Task.FromResult(new BatchTranslationResponse(results, successCount, failureCount, TimeSpan.Zero));
}
}

View File

@ -0,0 +1,46 @@
using BTCPayTranslator.Models;
using Xunit;
namespace BTCPayTranslator.Tests.Models;
public class SupportedLanguagesTests
{
[Fact]
public void GetLanguageInfo_ReturnsLanguage_WhenCodeExists()
{
var result = SupportedLanguages.GetLanguageInfo("es");
Assert.NotNull(result);
Assert.Equal("Spanish", result.Name);
Assert.Equal("es-ES", result.Code);
}
[Fact]
public void GetLanguageInfo_ReturnsNull_WhenCodeDoesNotExist()
{
var result = SupportedLanguages.GetLanguageInfo("does-not-exist");
Assert.Null(result);
}
[Fact]
public void GetAllLanguages_ReturnsConsistentAndValidLanguageCatalog()
{
var all = SupportedLanguages.GetAllLanguages().ToList();
Assert.NotEmpty(all);
Assert.Equal(SupportedLanguages.Languages.Count, all.Count);
Assert.Contains(all, l => l.Code == "es-ES" && l.Name == "Spanish");
Assert.Contains(all, l => l.Code == "fr-FR" && l.Name == "French");
Assert.Contains(all, l => l.Code == "ar" && l.IsRightToLeft);
Assert.All(all, l => Assert.False(string.IsNullOrWhiteSpace(l.Code)));
Assert.All(all, l => Assert.False(string.IsNullOrWhiteSpace(l.Name)));
Assert.All(all, l => Assert.False(string.IsNullOrWhiteSpace(l.NativeName)));
Assert.Equal(all.Count, all.Select(l => l.Code).Distinct().Count());
Assert.Equal(all.Count, all.Select(l => l.Name).Distinct().Count());
}
}

View File

@ -0,0 +1,23 @@
namespace BTCPayTranslator.Tests;
internal sealed class QueueHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public QueueHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
_responder = responder;
}
private int _callCount;
public int CallCount => Volatile.Read(ref _callCount);
public Uri? LastRequestUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _callCount);
LastRequestUri = request.RequestUri;
return Task.FromResult(_responder(request));
}
}

View File

@ -0,0 +1,135 @@
using BTCPayTranslator.Models;
using BTCPayTranslator.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Text;
using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace BTCPayTranslator.Tests.Services;
public class BaseTranslationServiceTests
{
[Fact]
public async Task TranslateAsync_ReturnsTranslatedText_WhenApiReturnsChoices()
{
var handler = new QueueHttpMessageHandler(responder: _ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"choices": [
{
"message": {
"content": "bonjour"
}
}
]
}
""", Encoding.UTF8, "application/json")
});
var fakeTime = new FakeTimeProvider();
using var service = CreateService(new HttpClient(handler), fakeTime);
var request = new TranslationRequest("hello", "Hello", "French");
var result = await service.TranslateAsync(request);
Assert.True(result.Success);
Assert.Equal("hello", result.Key);
Assert.Equal("bonjour", result.TranslatedText);
Assert.Equal(1, handler.CallCount);
service.Dispose();
}
[Fact]
public async Task TranslateAsync_ReturnsFailure_WhenApiReturnsNonSuccessStatus()
{
var handler = new QueueHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("bad request")
});
var fakeTime = new FakeTimeProvider();
using var service = CreateService(new HttpClient(handler), fakeTime);
var request = new TranslationRequest("hello", "Hello", "Spanish");
var translateTask = service.TranslateAsync(request);
while (!translateTask.IsCompleted)
{
await Task.Delay(10);
fakeTime.Advance(TimeSpan.FromSeconds(1));
}
var result = await translateTask;
Assert.False(result.Success);
Assert.Contains("API error", result.Error);
Assert.InRange(handler.CallCount, 2, 3);
service.Dispose();
}
[Fact]
public async Task TranslateBatchAsync_ReturnsResultForEachInputItem()
{
var handler = new QueueHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"choices": [
{
"message": {
"content": "Translated"
}
}
]
}
""", Encoding.UTF8, "application/json")
});
var fakeTime = new FakeTimeProvider();
using var service = CreateService(new HttpClient(handler), fakeTime);
var batch = new BatchTranslationRequest(
new List<TranslationRequest>
{
new("k1", "First", "French"),
new("k2", "Second", "French")
},
"French",
"Français");
var batchTask = service.TranslateBatchAsync(batch);
while (!batchTask.IsCompleted)
{
await Task.Delay(10);
fakeTime.Advance(TimeSpan.FromSeconds(1));
}
var result = await batchTask;
Assert.Equal(2, result.Results.Count);
Assert.Equal(2, result.SuccessCount);
Assert.Equal(0, result.FailureCount);
Assert.All(result.Results, r => Assert.True(r.Success));
Assert.Equal(2, handler.CallCount);
service.Dispose();
}
private static BaseTranslationService CreateService(HttpClient client, TimeProvider? timeProvider = null)
{
Environment.SetEnvironmentVariable("OPENROUTER_API_KEY", null);
Environment.SetEnvironmentVariable("OPENROUTER_MODEL", null);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["TranslationService:OpenRouter:ApiKey"] = "test-key",
["TranslationService:OpenRouter:Model"] = "test-model"
})
.Build();
return new BaseTranslationService(client, config, NullLogger<BaseTranslationService>.Instance, timeProvider);
}
}

View File

@ -0,0 +1,237 @@
using System.Text;
using BTCPayTranslator.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json.Linq;
using Xunit;
namespace BTCPayTranslator.Tests.Services;
public class FileWriterRefreshTests
{
// Build a CRLF JSON document from individual lines (no trailing newline unless requested).
private static string Crlf(params string[] lines) => string.Join("\r\n", lines);
private static FileWriter Sut() => new(NullLogger<FileWriter>.Instance);
private static Dictionary<string, string> Source(params (string Key, string Value)[] entries) =>
entries.ToDictionary(e => e.Key, e => e.Value);
[Fact]
public async Task InsertMissingKeysAsync_InsertsNewKey_InCorrectSortedPosition()
{
var file = WriteTemp(Crlf(
"{",
" \"a\": \"A\",",
" \"c\": \"C\"",
"}"));
try
{
var added = await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B"), ("c", "C")));
Assert.Equal(1, added);
var keys = JObject.Parse(await File.ReadAllTextAsync(file)).Properties().Select(p => p.Name).ToList();
Assert.Equal(new[] { "a", "b", "c" }, keys);
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_PlaceholderValue_EqualsEnglishSource()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "English B")));
var json = JObject.Parse(await File.ReadAllTextAsync(file));
Assert.Equal("English B", json["b"]!.Value<string>());
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_PreservesExistingLines_AndEmptyValues_AndNonAscii()
{
var existingLines = new[]
{
" \"_maintainer\": \"someone|https://example.com\",",
" \"déjà\": \"déjà vu\",",
" \"empty\": \"\",",
" \"zed\": \"Z\""
};
var file = WriteTemp(Crlf(new[] { "{" }.Concat(existingLines).Append("}").ToArray()));
try
{
var added = await Sut().InsertMissingKeysAsync(file, Source(("mango", "Mango"), ("zed", "Z")));
Assert.Equal(1, added);
var text = await File.ReadAllTextAsync(file);
// Every original entry line survives verbatim.
foreach (var line in existingLines)
Assert.Contains(line, text);
// Empty value preserved, non-ASCII left raw (no \u escapes anywhere).
Assert.DoesNotContain("\\u", text);
var json = JObject.Parse(text);
Assert.Equal("", json["empty"]!.Value<string>());
Assert.Equal("déjà vu", json["déjà"]!.Value<string>());
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_PreservesTrailingSpaceOnExistingLine()
{
// A non-last line that ends with ", " (comma + trailing space) must stay byte-identical.
var spacey = " \"a\": \"A\", ";
var file = WriteTemp(Crlf("{", spacey, " \"c\": \"C\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B"), ("c", "C")));
var lines = (await File.ReadAllTextAsync(file)).Split("\r\n");
Assert.Contains(spacey, lines);
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_DoesNotReorderExisting_InNonCanonicalOrderFile()
{
// Keys deliberately NOT in writer order.
var file = WriteTemp(Crlf(
"{",
" \"_maintainer\": \"x|https://e.com\",",
" \"zebra\": \"Z\",",
" \"alpha\": \"A\"",
"}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("zebra", "Z"), ("alpha", "A"), ("mango", "M")));
var keys = JObject.Parse(await File.ReadAllTextAsync(file)).Properties().Select(p => p.Name).ToList();
// Existing relative order is preserved; only positions of the 3 pre-existing keys matter here.
Assert.True(keys.IndexOf("_maintainer") < keys.IndexOf("zebra"));
Assert.True(keys.IndexOf("zebra") < keys.IndexOf("alpha"));
Assert.Contains("mango", keys);
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_IsIdempotent()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\",", " \"c\": \"C\"", "}"));
try
{
var first = await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B"), ("c", "C")));
var afterFirst = await File.ReadAllBytesAsync(file);
var second = await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B"), ("c", "C")));
var afterSecond = await File.ReadAllBytesAsync(file);
Assert.Equal(1, first);
Assert.Equal(0, second);
Assert.Equal(afterFirst, afterSecond);
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_PreservesTrailingNewline_WhenPresent()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}") + "\r\n");
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B")));
Assert.EndsWith("}\r\n", await File.ReadAllTextAsync(file));
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_PreservesNoTrailingNewline_WhenAbsent()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("b", "B")));
var text = await File.ReadAllTextAsync(file);
Assert.EndsWith("}", text);
Assert.False(text.EndsWith("}\r\n"));
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_InsertingAfterLastKey_FixesPreviousLastComma()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("z", "Z")));
var lines = (await File.ReadAllTextAsync(file)).Split("\r\n");
Assert.Equal(" \"a\": \"A\",", lines[1]); // gained a comma
Assert.Equal(" \"z\": \"Z\"", lines[2]); // new last, no comma
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_InsertsAtTop_WhenNewKeyPrecedesAllExisting()
{
var file = WriteTemp(Crlf("{", " \"m\": \"M\"", "}"));
try
{
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("m", "M")));
var keys = JObject.Parse(await File.ReadAllTextAsync(file)).Properties().Select(p => p.Name).ToList();
Assert.Equal(new[] { "a", "m" }, keys);
}
finally { Cleanup(file); }
}
[Fact]
public async Task InsertMissingKeysAsync_ReturnsZero_OnMissingFile()
{
var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
var added = await Sut().InsertMissingKeysAsync(missing, Source(("a", "A")));
Assert.Equal(0, added);
}
[Fact]
public async Task InsertMissingKeysAsync_RendersValueWithNewline_AsOnePhysicalLine()
{
var file = WriteTemp(Crlf("{", " \"a\": \"A\"", "}"));
try
{
// Source value contains an actual newline; it must be escaped as \n on a single line.
await Sut().InsertMissingKeysAsync(file, Source(("a", "A"), ("multi", "line1\nline2")));
var text = await File.ReadAllTextAsync(file);
var lines = text.Split("\r\n");
Assert.Equal(4, lines.Length); // { , "a" , "multi" , }
Assert.Contains(lines, l => l.Contains("\"multi\"") && l.Contains("line1\\nline2"));
Assert.Equal("line1\nline2", JObject.Parse(text)["multi"]!.Value<string>());
}
finally { Cleanup(file); }
}
private static string WriteTemp(string content)
{
var dir = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "french.json");
File.WriteAllText(path, content, new UTF8Encoding(false));
return path;
}
private static void Cleanup(string file)
{
var dir = Path.GetDirectoryName(file)!;
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}

View File

@ -0,0 +1,129 @@
using BTCPayTranslator.Models;
using BTCPayTranslator.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json.Linq;
using Xunit;
namespace BTCPayTranslator.Tests.Services;
public class FileWriterTests
{
[Fact]
public async Task WriteBackendTranslationFileAsync_WritesSortedJson()
{
var sut = new FileWriter(NullLogger<FileWriter>.Instance);
var language = SupportedLanguages.GetLanguageInfo("fr")!;
var tempDir = CreateTempDirectory();
var outputPath = Path.Combine(tempDir, "french.json");
try
{
var translations = new Dictionary<string, string>
{
["z"] = "Z",
["a"] = "A"
};
await sut.WriteBackendTranslationFileAsync(outputPath, language, translations);
Assert.True(File.Exists(outputPath));
var content = await File.ReadAllTextAsync(outputPath);
var json = JObject.Parse(content);
var keys = json.Properties().Select(p => p.Name).ToList();
Assert.Equal(new[] { "a", "z" }, keys);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task LoadExistingBackendTranslationsAsync_ReturnsEmpty_OnMissingFile()
{
var sut = new FileWriter(NullLogger<FileWriter>.Instance);
var result = await sut.LoadExistingBackendTranslationsAsync(Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".json"));
Assert.Empty(result);
}
[Fact]
public async Task LoadExistingBackendTranslationsAsync_SkipsEmptyValues()
{
var sut = new FileWriter(NullLogger<FileWriter>.Instance);
var tempFile = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(tempFile, """
{
"hello": "bonjour",
"empty": ""
}
""");
var result = await sut.LoadExistingBackendTranslationsAsync(tempFile);
Assert.Single(result);
Assert.Equal("bonjour", result["hello"]);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public async Task WriteSummaryReportAsync_WritesReportFile()
{
var sut = new FileWriter(NullLogger<FileWriter>.Instance);
var tempDir = CreateTempDirectory();
var outputPath = Path.Combine(tempDir, "french.json");
try
{
var response = new BatchTranslationResponse(
new List<TranslationResponse>
{
new("k1", "v1", true),
new("k2", "v2", false, "failed")
},
SuccessCount: 1,
FailureCount: 1,
Duration: TimeSpan.FromSeconds(1));
await sut.WriteSummaryReportAsync(outputPath, "French", response, new Dictionary<string, string> { ["k1"] = "v1" });
var reportPath = Path.ChangeExtension(outputPath, ".report.json");
Assert.True(File.Exists(reportPath));
var content = await File.ReadAllTextAsync(reportPath);
var report = JObject.Parse(content);
Assert.Equal("French", report["Language"]?.Value<string>());
Assert.Equal(1, report["Translation"]?["SuccessfulTranslations"]?.Value<int>());
Assert.Equal(1, report["Translation"]?["FailedTranslations"]?.Value<int>());
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
private static string CreateTempDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
}

View File

@ -0,0 +1,378 @@
using BTCPayTranslator.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json.Linq;
using Xunit;
namespace BTCPayTranslator.Tests.Services;
public class LanguagePackValidatorTests
{
[Fact]
public async Task ValidateAsync_ReturnsIssue_WhenOutputDirectoryDoesNotExist()
{
var missingDirectory = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
var sut = CreateSut(missingDirectory);
var result = await sut.ValidateAsync(fix: false);
Assert.Equal(0, result.FilesScanned);
Assert.Equal(0, result.EntriesScanned);
var issue = Assert.Single(result.Issues);
Assert.Equal("<none>", issue.FileName);
Assert.Contains("does not exist", issue.Reason);
}
[Fact]
public async Task ValidateAsync_ReportsInvalidJsonFiles()
{
var tempDir = CreateTempDirectory();
try
{
await File.WriteAllTextAsync(Path.Combine(tempDir, "broken.json"), "{\"hello\":");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);
Assert.Equal(1, result.FilesScanned);
Assert.Equal(0, result.EntriesScanned);
var issue = Assert.Single(result.Issues);
Assert.Equal("broken.json", issue.FileName);
Assert.Equal("<file>", issue.Key);
Assert.Contains("Invalid JSON", issue.Reason);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task ValidateAsync_WithFix_RewritesMetaAndPlaceholderIssues()
{
var tempDir = CreateTempDirectory();
try
{
var filePath = Path.Combine(tempDir, "french.json");
await File.WriteAllTextAsync(filePath, """
{
"code": "fr",
"currentLanguage": "French",
"hello {name}": "bonjour",
"prompt": "please provide the english text"
}
""");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: true);
Assert.Equal(1, result.FilesScanned);
Assert.Equal(4, result.EntriesScanned);
Assert.Equal(2, result.Issues.Count);
Assert.Contains(result.Issues, i => i.Key == "hello {name}" && i.Reason.Contains("Placeholder/token mismatch"));
Assert.Contains(result.Issues, i => i.Key == "prompt" && i.Reason.Contains("Suspicious LLM/meta-response"));
var written = JObject.Parse(await File.ReadAllTextAsync(filePath));
Assert.Equal("hello {name}", written["hello {name}"]?.Value<string>());
Assert.Equal("prompt", written["prompt"]?.Value<string>());
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task ValidateAsync_WithFix_RemovesShortHotspotAndSentenceFallback()
{
var tempDir = CreateTempDirectory();
try
{
var longKey = "This is a long sentence that should be translated";
var filePath = Path.Combine(tempDir, "french.json");
await File.WriteAllTextAsync(filePath, $$"""
{
"Confirm": "Confirm",
"{{longKey}}": "{{longKey}}",
"hello": "bonjour"
}
""");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: true);
Assert.Equal(2, result.Issues.Count);
Assert.Contains(result.Issues, i => i.Key == "Confirm" && i.Reason.Contains("left untranslated"));
Assert.Contains(result.Issues, i => i.Key == longKey && i.Reason.Contains("sentence-like"));
var written = JObject.Parse(await File.ReadAllTextAsync(filePath));
Assert.Null(written["Confirm"]);
Assert.Null(written[longKey]);
Assert.Equal("bonjour", written["hello"]?.Value<string>());
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task ValidateAsync_FlagsHtmlTagMismatch()
{
var tempDir = CreateTempDirectory();
try
{
var filePath = Path.Combine(tempDir, "hindi.json");
await File.WriteAllTextAsync(filePath, """
{
"<strong>Never</strong> trust anything but <code>id</code>": "केवल <code>id</code> पर भरोसा करें",
"kept-intact": "<code>foo</code> bar <code>baz</code>"
}
""".Replace("<code>foo</code> bar <code>baz</code>",
"<code>foo</code> bar <code>baz</code>"));
// Re-write with a balanced kept-intact entry so only the first entry fails the rule
await File.WriteAllTextAsync(filePath, """
{
"<strong>Never</strong> trust anything but <code>id</code>": "केवल <code>id</code> पर भरोसा करें",
"<code>foo</code>": "<code>foo</code>"
}
""");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);
Assert.Equal(2, result.EntriesScanned);
var issue = Assert.Single(result.Issues);
Assert.StartsWith("<strong>Never", issue.Key);
Assert.Contains("Structural HTML tag mismatch", issue.Reason);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task ValidateAsync_IgnoresExampleEmailAngleBrackets()
{
// The HTML-tag check uses a curated allowlist of structural elements
// (strong/em/code/br/p/a/etc.) so localized example data like
// "<email@primer.com>" doesn't trip the rule even though the bare
// HtmlTagRegex would match it.
var tempDir = CreateTempDirectory();
try
{
var filePath = Path.Combine(tempDir, "serbian.json");
await File.WriteAllTextAsync(filePath, """
{
"Firstname Lastname <email@example.com>": "Ime Prezime <email@primer.com>"
}
""");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);
Assert.Equal(1, result.EntriesScanned);
Assert.Empty(result.Issues);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task ValidateAsync_FlagsInvalidMaintainerField()
{
var tempDir = CreateTempDirectory();
try
{
var filePath = Path.Combine(tempDir, "bad-maintainer.json");
await File.WriteAllTextAsync(filePath, """
{
"_maintainer": "someone with no pipe or URL",
"hello": "bonjour"
}
""");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);
// _maintainer is not counted as a translation entry
Assert.Equal(1, result.EntriesScanned);
var issue = Assert.Single(result.Issues);
Assert.Equal("_maintainer", issue.Key);
Assert.Contains("Invalid _maintainer value", issue.Reason);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task ValidateAsync_AcceptsWellFormedMaintainerField()
{
var tempDir = CreateTempDirectory();
try
{
var filePath = Path.Combine(tempDir, "ok-maintainer.json");
await File.WriteAllTextAsync(filePath, """
{
"_maintainer": "thgO-O|https://github.com/thgO-O",
"hello": "olá"
}
""");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);
Assert.Equal(1, result.EntriesScanned);
Assert.Empty(result.Issues);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task ValidateAsync_RejectsMaintainerWithHttpScheme()
{
var tempDir = CreateTempDirectory();
try
{
var filePath = Path.Combine(tempDir, "http-maintainer.json");
await File.WriteAllTextAsync(filePath, """
{
"_maintainer": "thgO-O|http://github.com/thgO-O"
}
""");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);
var issue = Assert.Single(result.Issues);
Assert.Equal("_maintainer", issue.Key);
Assert.Contains("Invalid _maintainer", issue.Reason);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task ValidateAsync_AcceptsNullMaintainerField()
{
var tempDir = CreateTempDirectory();
try
{
var filePath = Path.Combine(tempDir, "null-maintainer.json");
await File.WriteAllTextAsync(filePath, """
{
"_maintainer": null,
"hello": "hei"
}
""");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);
Assert.Equal(1, result.EntriesScanned);
Assert.Empty(result.Issues);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task ValidateAsync_RejectsBlankMaintainerField_WhenPresent()
{
var tempDir = CreateTempDirectory();
try
{
var filePath = Path.Combine(tempDir, "blank-maintainer.json");
await File.WriteAllTextAsync(filePath, """
{
"_maintainer": " ",
"hello": "hei"
}
""");
var sut = CreateSut(tempDir);
var result = await sut.ValidateAsync(fix: false);
Assert.Equal(1, result.EntriesScanned);
var issue = Assert.Single(result.Issues);
Assert.Equal("_maintainer", issue.Key);
Assert.Contains("Invalid _maintainer", issue.Reason);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
private static LanguagePackValidator CreateSut(string outputDirectory)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = outputDirectory
})
.Build();
return new LanguagePackValidator(configuration, NullLogger<LanguagePackValidator>.Instance);
}
private static string CreateTempDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
}

View File

@ -0,0 +1,315 @@
using System.Security.Cryptography;
using System.Text.Json;
using BTCPayTranslator.Models;
using BTCPayTranslator.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace BTCPayTranslator.Tests.Services;
public class ManifestGeneratorTests
{
[Fact]
public async Task GenerateManifest_WritesManifest_ForValidTranslationFile()
{
var tempDir = CreateTempDirectory();
var translationsDir = Path.Combine(tempDir, "translations");
Directory.CreateDirectory(translationsDir);
var translationFile = Path.Combine(translationsDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");
try
{
await File.WriteAllTextAsync(translationFile, """
{
"_maintainer": "alice|https://github.com/alice",
"hello": "bonjour"
}
""");
var sut = CreateSut();
var result = await sut.GenerateManifest(translationsDir, manifestPath);
Assert.True(result);
Assert.True(File.Exists(manifestPath));
var manifest = await ReadManifest(manifestPath);
var entry = Assert.Single(manifest.Languages);
Assert.Equal("fr", entry.Code);
Assert.Equal("fr-FR", entry.Bcp47);
Assert.Equal("French", entry.Name);
Assert.Equal("Français", entry.Native);
Assert.Equal("translations/French.json", entry.File);
Assert.Equal("alice|https://github.com/alice", entry.Maintainer);
Assert.Equal(ComputeSha256(translationFile), entry.Sha);
Assert.Matches("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", entry.Updated);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task GenerateManifest_ReturnsFalse_WhenNoTranslationFilesExist()
{
var tempDir = CreateTempDirectory();
var manifestPath = Path.Combine(tempDir, "manifest.json");
try
{
var sut = CreateSut();
var result = await sut.GenerateManifest(tempDir, manifestPath);
Assert.False(result);
Assert.False(File.Exists(manifestPath));
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task GenerateManifest_ReturnsFalse_WhenTranslationDirectoryDoesNotExist()
{
var translationsDir = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
var manifestPath = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"), "manifest.json");
var sut = CreateSut();
var result = await sut.GenerateManifest(translationsDir, manifestPath);
Assert.False(result);
}
[Fact]
public async Task GenerateManifest_RetainsUpdated_WhenExistingShaMatches()
{
var tempDir = CreateTempDirectory();
var translationsDir = Path.Combine(tempDir, "translations");
Directory.CreateDirectory(translationsDir);
var translationFile = Path.Combine(translationsDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");
try
{
await File.WriteAllTextAsync(translationFile, """
{
"_maintainer": "alice|https://github.com/alice",
"hello": "bonjour"
}
""");
var existingSha = ComputeSha256(translationFile);
var expectedUpdated = "2024-01-02T03:04:05Z";
var existingManifest = new Manifest(
new List<ManifestEntry>
{
new(
Code: "fr",
Bcp47: "fr-FR",
Name: "French",
Native: "Français",
File: "translations/French.json",
Sha: existingSha,
Maintainer: "old",
Updated: expectedUpdated)
},
Redirect: null);
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(existingManifest));
var sut = CreateSut();
var result = await sut.GenerateManifest(translationsDir, manifestPath);
Assert.True(result);
var generated = await ReadManifest(manifestPath);
var entry = Assert.Single(generated.Languages);
Assert.Equal(expectedUpdated, entry.Updated);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task GenerateManifest_UpdatesUpdated_WhenExistingShaDiffers()
{
var tempDir = CreateTempDirectory();
var translationsDir = Path.Combine(tempDir, "translations");
Directory.CreateDirectory(translationsDir);
var translationFile = Path.Combine(translationsDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");
try
{
await File.WriteAllTextAsync(translationFile, """
{
"_maintainer": "alice|https://github.com/alice",
"hello": "bonjour"
}
""");
var previousUpdated = "2024-01-02T03:04:05Z";
var existingManifest = new Manifest(
new List<ManifestEntry>
{
new(
Code: "fr",
Bcp47: "fr-FR",
Name: "French",
Native: "Français",
File: "translations/French.json",
Sha: "deadbeef",
Maintainer: "old",
Updated: previousUpdated)
},
Redirect: null);
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(existingManifest));
var sut = CreateSut();
var result = await sut.GenerateManifest(translationsDir, manifestPath);
Assert.True(result);
var generated = await ReadManifest(manifestPath);
var entry = Assert.Single(generated.Languages);
Assert.NotEqual(previousUpdated, entry.Updated);
Assert.Matches("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$", entry.Updated);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task GenerateManifest_SetsMaintainerToNull_WhenFieldMissing()
{
var tempDir = CreateTempDirectory();
var translationFile = Path.Combine(tempDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");
try
{
await File.WriteAllTextAsync(translationFile, """
{
"hello": "bonjour"
}
""");
var sut = CreateSut();
var result = await sut.GenerateManifest(tempDir, manifestPath);
Assert.True(result);
var manifest = await ReadManifest(manifestPath);
var entry = Assert.Single(manifest.Languages);
Assert.Null(entry.Maintainer);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task GenerateManifest_ReturnsFalse_WhenLanguageNameMappingIsMissing()
{
var tempDir = CreateTempDirectory();
var translationFile = Path.Combine(tempDir, "Klingon.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");
try
{
await File.WriteAllTextAsync(translationFile, """
{
"_maintainer": "alice|https://github.com/alice",
"hello": "nuqneH"
}
""");
var sut = CreateSut();
var result = await sut.GenerateManifest(tempDir, manifestPath);
Assert.False(result);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task GenerateManifest_ReturnsFalse_WhenTranslationFileHasInvalidJson()
{
var tempDir = CreateTempDirectory();
var translationFile = Path.Combine(tempDir, "French.json");
var manifestPath = Path.Combine(tempDir, "manifest.json");
try
{
await File.WriteAllTextAsync(translationFile, "{\"_maintainer\":");
var sut = CreateSut();
var result = await sut.GenerateManifest(tempDir, manifestPath);
Assert.False(result);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
private static ManifestGenerator CreateSut()
{
return new ManifestGenerator(NullLogger<ManifestGenerator>.Instance);
}
private static async Task<Manifest> ReadManifest(string path)
{
var json = await File.ReadAllTextAsync(path);
var manifest = JsonSerializer.Deserialize<Manifest>(json);
return Assert.IsType<Manifest>(manifest);
}
private static string ComputeSha256(string path)
{
using var sha256 = SHA256.Create();
using var stream = File.OpenRead(path);
var hash = sha256.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string CreateTempDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
}

View File

@ -0,0 +1,177 @@
using BTCPayTranslator.Services;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net;
using System.Text;
using Xunit;
namespace BTCPayTranslator.Tests.Services;
public class TranslationExtractorTests
{
private static TranslationExtractor CreateSut()
{
return new TranslationExtractor(NullLogger<TranslationExtractor>.Instance, new HttpClient());
}
private static TranslationExtractor CreateSut(HttpClient httpClient)
{
return new TranslationExtractor(NullLogger<TranslationExtractor>.Instance, httpClient);
}
[Fact]
public void MergeTranslations_OverridesExistingAndAddsNewKeys()
{
var sut = CreateSut();
var existing = new Dictionary<string, string>
{
["hello"] = "bonjour",
["bye"] = "au revoir"
};
var incoming = new Dictionary<string, string>
{
["bye"] = "salut",
["thanks"] = "merci"
};
var merged = sut.MergeTranslations(existing, incoming);
Assert.Equal(3, merged.Count);
Assert.Equal("bonjour", merged["hello"]);
Assert.Equal("salut", merged["bye"]);
Assert.Equal("merci", merged["thanks"]);
}
[Fact]
public void GetTranslationsToUpdate_ReturnsOnlyMissingKeys()
{
var sut = CreateSut();
var source = new Dictionary<string, string>
{
["hello"] = "Hello",
["bye"] = "Goodbye",
["thanks"] = "Thanks"
};
var existing = new Dictionary<string, string>
{
["hello"] = "Hello",
["bye"] = "Old value"
};
var toUpdate = sut.GetTranslationsToUpdate(source, existing);
Assert.Single(toUpdate);
Assert.Equal("Thanks", toUpdate["thanks"]);
Assert.False(toUpdate.ContainsKey("hello"));
Assert.False(toUpdate.ContainsKey("bye"));
}
[Fact]
public void GetTranslationsToUpdate_ReturnsEmpty_WhenAllKeysAlreadyExist()
{
var sut = CreateSut();
var source = new Dictionary<string, string>
{
["hello"] = "Hello",
["bye"] = "Goodbye"
};
var existing = new Dictionary<string, string>
{
["hello"] = "Hello",
["bye"] = "Goodbye"
};
var toUpdate = sut.GetTranslationsToUpdate(source, existing);
Assert.Empty(toUpdate);
}
[Fact]
public async Task ExtractFromBTCPayServerAsync_ReplacesEmptyValuesWithKeys()
{
var handler = new QueueHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"hello\":\"Hello\",\"bye\":\"\"}", Encoding.UTF8, "application/json")
});
var sut = CreateSut(new HttpClient(handler));
var result = await sut.ExtractFromBTCPayServerAsync("https://btcpay.test");
Assert.Equal("Hello", result["hello"]);
Assert.Equal("bye", result["bye"]);
Assert.Equal(1, handler.CallCount);
}
[Fact]
public async Task ExtractFromDefaultFileAsync_ParsesKnownTranslationsBlock()
{
var sut = CreateSut();
var tempFile = Path.GetTempFileName();
try
{
var content = "public class Seed\n" +
"{\n" +
" public void Load()\n" +
" {\n" +
" var knownTranslations = \"\"\"\n" +
"{\n" +
" \"hello\": \"Hello\",\n" +
" \"bye\": \"\"\n" +
"}\n" +
"\"\"\";\n" +
" }\n" +
"}\n";
await File.WriteAllTextAsync(tempFile, content);
var result = await sut.ExtractFromDefaultFileAsync(tempFile);
Assert.Equal("Hello", result["hello"]);
Assert.Equal("bye", result["bye"]);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public async Task LoadExistingTranslationsAsync_SkipsMetadataAndEmptyValues()
{
var sut = CreateSut();
var tempFile = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(tempFile, """
{
"NOTICE_WARN": "warn",
"code": "fr-FR",
"currentLanguage": "French",
"hello": "bonjour",
"empty": ""
}
""");
var result = await sut.LoadExistingTranslationsAsync(tempFile);
Assert.Single(result);
Assert.Equal("bonjour", result["hello"]);
Assert.False(result.ContainsKey("NOTICE_WARN"));
Assert.False(result.ContainsKey("empty"));
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
}

View File

@ -0,0 +1,488 @@
using BTCPayTranslator.Models;
using BTCPayTranslator.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace BTCPayTranslator.Tests.Services;
public class TranslationOrchestratorTests
{
[Fact]
public async Task GetSourceTranslationsAsync_UsesBTCPayEndpoint_WhenConfigured()
{
var tempDir = CreateTempDirectory();
try
{
var handler = new QueueHttpMessageHandler(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("{\"hello\":\"Hello\"}")
});
var extractor = new TranslationExtractor(
NullLogger<TranslationExtractor>.Instance,
new HttpClient(handler));
var orchestrator = CreateOrchestrator(
extractor,
new FileWriter(NullLogger<FileWriter>.Instance),
new FakeTranslationService(),
new Dictionary<string, string?>
{
["Translation:BTCPayUrl"] = "https://btcpay.test",
["Translation:OutputDirectory"] = tempDir
});
var source = await orchestrator.GetSourceTranslationsAsync();
Assert.Single(source);
Assert.Equal("Hello", source["hello"]);
Assert.Equal("https://btcpay.test/cheat/translations/default-en", handler.LastRequestUri?.ToString());
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task TranslateToLanguageAsync_ReturnsFalse_ForUnsupportedLanguage()
{
var tempDir = CreateTempDirectory();
try
{
var (extractor, inputFile) = CreateExtractorFromKnownTranslationsFile(tempDir);
var orchestrator = CreateOrchestrator(
extractor,
new FileWriter(NullLogger<FileWriter>.Instance),
new FakeTranslationService(),
new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = tempDir,
["Translation:InputFile"] = inputFile
});
var success = await orchestrator.TranslateToLanguageAsync("xx");
Assert.False(success);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task TranslateToLanguageAsync_CreatesOutputFile_WithMergedTranslations()
{
var tempDir = CreateTempDirectory();
try
{
var (extractor, inputFile) = CreateExtractorFromKnownTranslationsFile(tempDir);
var fakeService = new FakeTranslationService(r => new TranslationResponse(r.Key, $"fr-{r.SourceText}", true));
var fileWriter = new FileWriter(NullLogger<FileWriter>.Instance);
var language = SupportedLanguages.GetLanguageInfo("fr")!;
var outputPath = Path.Combine(tempDir, $"{language.Name.ToLower()}.json");
await fileWriter.WriteBackendTranslationFileAsync(
outputPath,
language,
new Dictionary<string, string> { ["existing"] = "value" });
var orchestrator = CreateOrchestrator(
extractor,
fileWriter,
fakeService,
new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = tempDir,
["Translation:InputFile"] = inputFile,
["Translation:BatchSize"] = "10",
["Translation:DelayBetweenRequests"] = "0"
});
var success = await orchestrator.TranslateToLanguageAsync("fr");
Assert.True(success);
Assert.Equal(2, fakeService.SeenRequests.Count);
var written = await fileWriter.LoadExistingBackendTranslationsAsync(outputPath);
Assert.Equal(3, written.Count);
Assert.Equal("value", written["existing"]);
Assert.Equal("fr-Hello", written["hello"]);
Assert.Equal("fr-bye", written["bye"]);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task TranslateToLanguageAsync_ReturnsTrue_WhenNoNewKeys()
{
var tempDir = CreateTempDirectory();
try
{
var (extractor, inputFile) = CreateExtractorFromKnownTranslationsFile(tempDir);
var fakeService = new FakeTranslationService();
var fileWriter = new FileWriter(NullLogger<FileWriter>.Instance);
var language = SupportedLanguages.GetLanguageInfo("fr")!;
var outputPath = Path.Combine(tempDir, $"{language.Name.ToLower()}.json");
await fileWriter.WriteBackendTranslationFileAsync(
outputPath,
language,
new Dictionary<string, string>
{
["hello"] = "bonjour",
["bye"] = "au revoir"
});
var orchestrator = CreateOrchestrator(
extractor,
fileWriter,
fakeService,
new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = tempDir,
["Translation:InputFile"] = inputFile
});
var success = await orchestrator.TranslateToLanguageAsync("fr");
Assert.True(success);
Assert.Empty(fakeService.SeenRequests);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task UpdateLanguageAsync_AddsNewAndRemovesDeletedKeys()
{
var tempDir = CreateTempDirectory();
try
{
var (extractor, inputFile) = CreateExtractorFromKnownTranslationsFile(tempDir);
var fakeService = new FakeTranslationService(r => new TranslationResponse(r.Key, $"upd-{r.SourceText}", true));
var fileWriter = new FileWriter(NullLogger<FileWriter>.Instance);
var language = SupportedLanguages.GetLanguageInfo("fr")!;
var outputPath = Path.Combine(tempDir, $"{language.Name.ToLower()}.json");
await fileWriter.WriteBackendTranslationFileAsync(
outputPath,
language,
new Dictionary<string, string>
{
["hello"] = "bonjour",
["obsolete"] = "obsolète"
});
var orchestrator = CreateOrchestrator(
extractor,
fileWriter,
fakeService,
new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = tempDir,
["Translation:InputFile"] = inputFile,
["Translation:BatchSize"] = "10",
["Translation:DelayBetweenRequests"] = "0"
});
var success = await orchestrator.UpdateLanguageAsync("fr");
Assert.True(success);
Assert.Single(fakeService.SeenRequests);
Assert.Equal("bye", fakeService.SeenRequests[0].Key);
var written = await fileWriter.LoadExistingBackendTranslationsAsync(outputPath);
Assert.Equal(2, written.Count);
Assert.Equal("bonjour", written["hello"]);
Assert.Equal("upd-bye", written["bye"]);
Assert.False(written.ContainsKey("obsolete"));
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task UpdateAllLanguagesAsync_UpdatesOnlyKnownLanguageFiles()
{
var tempDir = CreateTempDirectory();
try
{
var (extractor, inputFile) = CreateExtractorFromKnownTranslationsFile(tempDir);
var fakeService = new FakeTranslationService(r => new TranslationResponse(r.Key, $"all-{r.SourceText}", true));
var fileWriter = new FileWriter(NullLogger<FileWriter>.Instance);
await fileWriter.WriteBackendTranslationFileAsync(
Path.Combine(tempDir, "french.json"),
SupportedLanguages.GetLanguageInfo("fr")!,
new Dictionary<string, string> { ["hello"] = "bonjour" });
await File.WriteAllTextAsync(Path.Combine(tempDir, "unknown.json"), "{\"test\":\"test\"}");
var orchestrator = CreateOrchestrator(
extractor,
fileWriter,
fakeService,
new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = tempDir,
["Translation:InputFile"] = inputFile,
["Translation:BatchSize"] = "10",
["Translation:DelayBetweenRequests"] = "0"
});
var results = await orchestrator.UpdateAllLanguagesAsync();
Assert.Single(results);
Assert.True(results["fr"]);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task RefreshKeysAsync_AddsMissingKeys_FromLocalSource_WithoutTranslating()
{
var tempDir = CreateTempDirectory();
try
{
var (extractor, inputFile) = CreateExtractorFromKnownTranslationsFile(tempDir);
var fileWriter = new FileWriter(NullLogger<FileWriter>.Instance);
await fileWriter.WriteBackendTranslationFileAsync(
Path.Combine(tempDir, "french.json"),
SupportedLanguages.GetLanguageInfo("fr")!,
new Dictionary<string, string> { ["hello"] = "bonjour" });
var orchestrator = CreateOrchestrator(
extractor,
fileWriter,
new FakeTranslationService(),
new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = tempDir,
["Translation:InputFile"] = inputFile
});
var result = await orchestrator.RefreshKeysAsync();
Assert.Equal(1, result.FilesProcessed);
Assert.Equal(1, result.TotalKeysAdded);
Assert.Equal(1, result.AddedByFile["french.json"]);
var written = await fileWriter.LoadExistingBackendTranslationsAsync(Path.Combine(tempDir, "french.json"));
Assert.Equal("bonjour", written["hello"]); // existing translation untouched
Assert.Equal("bye", written["bye"]); // new key inserted as English placeholder
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task RefreshKeysAsync_SkipsUnknownLanguageFiles()
{
var tempDir = CreateTempDirectory();
try
{
var (extractor, inputFile) = CreateExtractorFromKnownTranslationsFile(tempDir);
var fileWriter = new FileWriter(NullLogger<FileWriter>.Instance);
await fileWriter.WriteBackendTranslationFileAsync(
Path.Combine(tempDir, "french.json"),
SupportedLanguages.GetLanguageInfo("fr")!,
new Dictionary<string, string> { ["hello"] = "bonjour" });
await File.WriteAllTextAsync(Path.Combine(tempDir, "unknown.json"), "{\r\n \"test\": \"test\"\r\n}");
var orchestrator = CreateOrchestrator(
extractor,
fileWriter,
new FakeTranslationService(),
new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = tempDir,
["Translation:InputFile"] = inputFile
});
var result = await orchestrator.RefreshKeysAsync();
Assert.Equal(1, result.FilesProcessed);
Assert.Equal(1, result.FilesSkipped);
Assert.True(result.AddedByFile.ContainsKey("french.json"));
Assert.False(result.AddedByFile.ContainsKey("unknown.json"));
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task RefreshKeysAsync_RespectsLanguageFilter()
{
var tempDir = CreateTempDirectory();
try
{
var (extractor, inputFile) = CreateExtractorFromKnownTranslationsFile(tempDir);
var fileWriter = new FileWriter(NullLogger<FileWriter>.Instance);
await fileWriter.WriteBackendTranslationFileAsync(
Path.Combine(tempDir, "french.json"),
SupportedLanguages.GetLanguageInfo("fr")!,
new Dictionary<string, string> { ["hello"] = "bonjour" });
await fileWriter.WriteBackendTranslationFileAsync(
Path.Combine(tempDir, "german.json"),
SupportedLanguages.GetLanguageInfo("de")!,
new Dictionary<string, string> { ["hello"] = "hallo" });
var orchestrator = CreateOrchestrator(
extractor,
fileWriter,
new FakeTranslationService(),
new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = tempDir,
["Translation:InputFile"] = inputFile
});
var result = await orchestrator.RefreshKeysAsync(new[] { "fr" });
Assert.Equal(1, result.FilesProcessed);
Assert.True(result.AddedByFile.ContainsKey("french.json"));
Assert.False(result.AddedByFile.ContainsKey("german.json"));
var german = await fileWriter.LoadExistingBackendTranslationsAsync(Path.Combine(tempDir, "german.json"));
Assert.False(german.ContainsKey("bye")); // untouched
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
[Fact]
public async Task RefreshKeysAsync_IsIdempotent_SecondRunAddsZero()
{
var tempDir = CreateTempDirectory();
try
{
var (extractor, inputFile) = CreateExtractorFromKnownTranslationsFile(tempDir);
var fileWriter = new FileWriter(NullLogger<FileWriter>.Instance);
await fileWriter.WriteBackendTranslationFileAsync(
Path.Combine(tempDir, "french.json"),
SupportedLanguages.GetLanguageInfo("fr")!,
new Dictionary<string, string> { ["hello"] = "bonjour" });
var orchestrator = CreateOrchestrator(
extractor,
fileWriter,
new FakeTranslationService(),
new Dictionary<string, string?>
{
["Translation:OutputDirectory"] = tempDir,
["Translation:InputFile"] = inputFile
});
var first = await orchestrator.RefreshKeysAsync();
var second = await orchestrator.RefreshKeysAsync();
Assert.Equal(1, first.TotalKeysAdded);
Assert.Equal(0, second.TotalKeysAdded);
}
finally
{
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, recursive: true);
}
}
}
private static TranslationOrchestrator CreateOrchestrator(
TranslationExtractor extractor,
FileWriter fileWriter,
ITranslationService translationService,
Dictionary<string, string?> settings)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(settings)
.Build();
return new TranslationOrchestrator(
translationService,
extractor,
fileWriter,
configuration,
NullLogger<TranslationOrchestrator>.Instance);
}
private static (TranslationExtractor, string) CreateExtractorFromKnownTranslationsFile(string baseDirectory)
{
var filePath = Path.Combine(baseDirectory, "Translations.Default.cs");
var content = "public class Seed\n" +
"{\n" +
" public void Load()\n" +
" {\n" +
" var knownTranslations = \"\"\"\n" +
"{\n" +
" \"hello\": \"Hello\",\n" +
" \"bye\": \"\"\n" +
"}\n" +
"\"\"\";\n" +
" }\n" +
"}\n";
File.WriteAllText(filePath, content);
var extractor = new TranslationExtractor(NullLogger<TranslationExtractor>.Instance, new HttpClient());
return (extractor, filePath);
}
private static string CreateTempDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "BTCPayTranslator.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
}

View File

@ -2,22 +2,21 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageReference Include="System.Runtime.Serialization.Primitives" Version="4.3.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace BTCPayTranslator.Models;
public record LanguageInfo(
string Code,
string Name,
string NativeName,
bool IsRightToLeft = false
);
public static class SupportedLanguages
{
public static readonly Dictionary<string, LanguageInfo> Languages = new()
{
["hi"] = new LanguageInfo("hi", "Hindi", "हिंदी"),
["es"] = new LanguageInfo("es-ES", "Spanish", "Español"),
["fr"] = new LanguageInfo("fr-FR", "French", "Français"),
["de"] = new LanguageInfo("de-DE", "German", "Deutsch"),
["it"] = new LanguageInfo("it-IT", "Italian", "Italiano"),
["pt"] = new LanguageInfo("pt-BR", "Portuguese (Brazil)", "Português (Brasil)"),
["ru"] = new LanguageInfo("ru-RU", "Russian", "Русский"),
["ja"] = new LanguageInfo("ja-JP", "Japanese", "日本語"),
["ko"] = new LanguageInfo("ko", "Korean", "한국어"),
["zh-cn"] = new LanguageInfo("zh-SG", "Chinese (Simplified)", "简体中文"),
["zh-tw"] = new LanguageInfo("zh-TW", "Chinese (Traditional)", "繁體中文"),
["ar"] = new LanguageInfo("ar", "Arabic", "العربية", true),
["he"] = new LanguageInfo("he", "Hebrew", "עברית", true),
["fa"] = new LanguageInfo("fa", "Persian", "فارسی", true),
["tr"] = new LanguageInfo("tr", "Turkish", "Türkçe"),
["nl"] = new LanguageInfo("nl-NL", "Dutch", "Nederlands"),
["sv"] = new LanguageInfo("sv", "Swedish", "Svenska"),
["no"] = new LanguageInfo("no", "Norwegian", "Norsk"),
["da"] = new LanguageInfo("da-DK", "Danish", "Dansk"),
["fi"] = new LanguageInfo("fi-FI", "Finnish", "Suomi"),
["pl"] = new LanguageInfo("pl", "Polish", "Polski"),
["cs"] = new LanguageInfo("cs-CZ", "Czech", "Čeština"),
["sk"] = new LanguageInfo("sk-SK", "Slovak", "Slovenčina"),
["hu"] = new LanguageInfo("hu-HU", "Hungarian", "Magyar"),
["ro"] = new LanguageInfo("ro", "Romanian", "Română"),
["bg"] = new LanguageInfo("bg-BG", "Bulgarian", "Български"),
["hr"] = new LanguageInfo("hr-HR", "Croatian", "Hrvatski"),
["sr"] = new LanguageInfo("sr", "Serbian", "Српски"),
["sl"] = new LanguageInfo("sl-SI", "Slovenian", "Slovenščina"),
["et"] = new LanguageInfo("et", "Estonian", "Eesti"),
["lv"] = new LanguageInfo("lv", "Latvian", "Latviešu"),
["lt"] = new LanguageInfo("lt", "Lithuanian", "Lietuvių"),
["uk"] = new LanguageInfo("uk-UA", "Ukrainian", "Українська"),
["be"] = new LanguageInfo("be", "Belarusian", "Беларуская"),
["el"] = new LanguageInfo("el-GR", "Greek", "Ελληνικά"),
["th"] = new LanguageInfo("th-TH", "Thai", "ไทย"),
["vi"] = new LanguageInfo("vi-VN", "Vietnamese", "Tiếng Việt"),
["id"] = new LanguageInfo("id", "Indonesian", "Bahasa Indonesia"),
["ms"] = new LanguageInfo("ms", "Malay", "Bahasa Melayu"),
["tl"] = new LanguageInfo("tl", "Filipino", "Filipino"),
["bn"] = new LanguageInfo("bn", "Bengali", "বাংলা"),
["ta"] = new LanguageInfo("ta", "Tamil", "தமிழ்"),
["te"] = new LanguageInfo("te", "Telugu", "తెలుగు"),
["ml"] = new LanguageInfo("ml", "Malayalam", "മലയാളം"),
["kn"] = new LanguageInfo("kn", "Kannada", "ಕನ್ನಡ"),
["gu"] = new LanguageInfo("gu", "Gujarati", "ગુજરાતી"),
["mr"] = new LanguageInfo("mr", "Marathi", "मराठी"),
["pa"] = new LanguageInfo("pa", "Punjabi", "ਪੰਜਾਬੀ"),
["or"] = new LanguageInfo("or", "Odia", "ଓଡ଼ିଆ"),
["as"] = new LanguageInfo("as", "Assamese", "অসমীয়া"),
["ur"] = new LanguageInfo("ur", "Urdu", "اردو", true),
["ne"] = new LanguageInfo("np-NP", "Nepali", "नेपाली"),
["si"] = new LanguageInfo("si", "Sinhala", "සිංහල"),
["my"] = new LanguageInfo("my", "Myanmar", "မြန်မာ"),
["km"] = new LanguageInfo("km", "Khmer", "ខ្មែរ"),
["lo"] = new LanguageInfo("lo", "Lao", "ລາວ"),
["ka"] = new LanguageInfo("ka", "Georgian", "ქართული"),
["hy"] = new LanguageInfo("hy", "Armenian", "Հայերեն"),
["az"] = new LanguageInfo("az", "Azerbaijani", "Azərbaycan"),
["kk"] = new LanguageInfo("kk-KZ", "Kazakh", "Қазақша"),
["ky"] = new LanguageInfo("ky", "Kyrgyz", "Кыргызча"),
["uz"] = new LanguageInfo("uz", "Uzbek", "O'zbek"),
["tg"] = new LanguageInfo("tg", "Tajik", "Тоҷикӣ"),
["mn"] = new LanguageInfo("mn", "Mongolian", "Монгол"),
["am"] = new LanguageInfo("am-ET", "Amharic", "አማርኛ"),
["sw"] = new LanguageInfo("sw", "Swahili", "Kiswahili"),
["zu"] = new LanguageInfo("zu", "Zulu", "isiZulu"),
["af"] = new LanguageInfo("af", "Afrikaans", "Afrikaans"),
["is"] = new LanguageInfo("is-IS", "Icelandic", "Íslenska"),
["fo"] = new LanguageInfo("fo", "Faroese", "Føroyskt"),
["mt"] = new LanguageInfo("mt", "Maltese", "Malti"),
["cy"] = new LanguageInfo("cy", "Welsh", "Cymraeg"),
["ga"] = new LanguageInfo("ga", "Irish", "Gaeilge"),
["gd"] = new LanguageInfo("gd", "Scottish Gaelic", "Gàidhlig"),
["eu"] = new LanguageInfo("eu", "Basque", "Euskera"),
["ca"] = new LanguageInfo("ca-ES", "Catalan", "Català"),
["gl"] = new LanguageInfo("gl", "Galician", "Galego"),
["ast"] = new LanguageInfo("ast", "Asturian", "Asturianu"),
["br"] = new LanguageInfo("br", "Breton", "Brezhoneg"),
["co"] = new LanguageInfo("co", "Corsican", "Corsu"),
["sc"] = new LanguageInfo("sc", "Sardinian", "Sardu"),
["lb"] = new LanguageInfo("lb", "Luxembourgish", "Lëtzebuergesch"),
["rm"] = new LanguageInfo("rm", "Romansh", "Rumantsch"),
["fur"] = new LanguageInfo("fur", "Friulian", "Furlan"),
["vec"] = new LanguageInfo("vec", "Venetian", "Vèneto"),
["nap"] = new LanguageInfo("nap", "Neapolitan", "Napulitano"),
["scn"] = new LanguageInfo("scn", "Sicilian", "Sicilianu"),
["lmo"] = new LanguageInfo("lmo", "Lombard", "Lumbaart"),
["pms"] = new LanguageInfo("pms", "Piedmontese", "Piemontèis"),
["lij"] = new LanguageInfo("lij", "Ligurian", "Ligure"),
["eml"] = new LanguageInfo("eml", "Emilian-Romagnol", "Emiliàn"),
["bs"] = new LanguageInfo("bs-BA", "Bosnian", "Bosanski"),
["mk"] = new LanguageInfo("mk", "Macedonian", "Македонски"),
["sq"] = new LanguageInfo("sq", "Albanian", "Shqip"),
["cnr"] = new LanguageInfo("cnr", "Montenegrin", "Crnogorski")
};
public static LanguageInfo? GetLanguageInfo(string code)
{
return Languages.TryGetValue(code, out var info) ? info : null;
}
public static IEnumerable<LanguageInfo> GetAllLanguages()
{
return Languages.Values;
}
public static (string Code, LanguageInfo)? GetLanguageInfoByName(string name)
{
var match = Languages.FirstOrDefault(kvp =>
kvp.Value.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (match.Key == null) return null;
return (match.Key, match.Value);
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace BTCPayTranslator.Models;
public record ManifestEntry(
string Code, // "fr"
string Bcp47, // "fr-FR"
string Name, // "French"
string Native, // "Français"
string File, // "translations/french.json"
string Sha, // "abc123..."
string? Maintainer, // "teamssUTXO|https://github.com/teamssUTXO"
string Updated // 2026-04-12T10:30:00Z
);
public record Manifest(
List<ManifestEntry> Languages,
string? Redirect
);

631
Translator/Program.cs Normal file
View File

@ -0,0 +1,631 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.CommandLine;
using BTCPayTranslator.Models;
using BTCPayTranslator.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using DotNetEnv;
namespace BTCPayTranslator;
class Program
{
static async Task<int> Main(string[] args)
{
// Load .env file if it exists
var envPath = Path.Combine(Directory.GetCurrentDirectory(), ".env");
if (File.Exists(envPath))
{
Env.Load(envPath);
}
// Build configuration
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.AddEnvironmentVariables()
.Build();
// Setup dependency injection
var serviceCollection = new ServiceCollection();
ConfigureServices(serviceCollection, configuration);
var serviceProvider = serviceCollection.BuildServiceProvider();
// Create command line interface
var rootCommand = new RootCommand("BTCPay Server Translation Tool - Translate BTCPay Server to multiple languages using AI")
{
CreateTranslateCommand(serviceProvider),
CreateListLanguagesCommand(),
CreateBatchCommand(serviceProvider),
CreateStatusCommand(serviceProvider),
CreateUpdateCommand(serviceProvider),
CreateBatchUpdateCommand(serviceProvider),
CreateUpdateAllCommand(serviceProvider),
CreateRefreshKeysCommand(serviceProvider),
CreateValidatePacksCommand(serviceProvider),
CreateGenerateManifestCommand(serviceProvider)
};
return await rootCommand.InvokeAsync(args);
}
private static void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton(configuration);
services.AddLogging(builder =>
{
builder.AddConsole();
builder.AddConfiguration(configuration.GetSection("Logging"));
});
services.AddHttpClient();
services.AddTransient<TranslationExtractor>();
services.AddTransient<FileWriter>();
services.AddTransient<TranslationOrchestrator>();
services.AddTransient<LanguagePackValidator>();
services.AddTransient<ITranslationService, BaseTranslationService>();
services.AddTransient<ManifestGenerator>();
}
private static Option<string?> CreateBTCPayUrlOption() =>
new Option<string?>(
"--btcpay-url",
"Base URL of a BTCPay Server running in debug/cheat mode " +
"(e.g. http://localhost:14142). When set, translations are fetched " +
"from the /cheat/translations/default-en endpoint instead of GitHub.")
{
IsRequired = false
};
private static void ApplyBTCPayUrl(IServiceProvider sp, string? btcpayUrl)
{
if (!string.IsNullOrWhiteSpace(btcpayUrl))
sp.GetRequiredService<IConfiguration>()["Translation:BTCPayUrl"] = btcpayUrl;
}
private static void ApplyInputFile(IServiceProvider sp, string? sourceFile)
{
if (!string.IsNullOrWhiteSpace(sourceFile))
sp.GetRequiredService<IConfiguration>()["Translation:InputFile"] = sourceFile;
}
private static Command CreateTranslateCommand(ServiceProvider serviceProvider)
{
var languageOption = new Option<string>(
"--language",
"Language code to translate to (e.g., 'hi', 'es', 'fr')")
{
IsRequired = true
};
var forceOption = new Option<bool>(
"--force",
"Force retranslation of all strings, even if translations already exist");
var btcpayUrlOption = CreateBTCPayUrlOption();
var command = new Command("translate", "Translate BTCPay Server to a specific language")
{
languageOption,
forceOption,
btcpayUrlOption
};
command.SetHandler(async (language, force, btcpayUrl) =>
{
using var scope = serviceProvider.CreateScope();
ApplyBTCPayUrl(scope.ServiceProvider, btcpayUrl);
var orchestrator = scope.ServiceProvider.GetRequiredService<TranslationOrchestrator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Starting translation for language: {Language}", language);
var success = await orchestrator.TranslateToLanguageAsync(language, force);
if (success)
{
logger.LogInformation("Translation completed successfully!");
}
else
{
logger.LogError("Translation failed!");
Environment.Exit(1);
}
}, languageOption, forceOption, btcpayUrlOption);
return command;
}
private static Command CreateBatchCommand(ServiceProvider serviceProvider)
{
var languagesOption = new Option<string[]>(
"--languages",
"Multiple language codes to translate to (e.g., 'hi es fr')")
{
IsRequired = true,
AllowMultipleArgumentsPerToken = true
};
var forceOption = new Option<bool>(
"--force",
"Force retranslation of all strings, even if translations already exist");
var continueOnErrorOption = new Option<bool>(
"--continue-on-error",
"Continue processing other languages if one fails")
{
IsRequired = false
};
var btcpayUrlOption = CreateBTCPayUrlOption();
var command = new Command("batch", "Translate BTCPay Server to multiple languages")
{
languagesOption,
forceOption,
continueOnErrorOption,
btcpayUrlOption
};
command.SetHandler(async (languages, force, continueOnError, btcpayUrl) =>
{
using var scope = serviceProvider.CreateScope();
ApplyBTCPayUrl(scope.ServiceProvider, btcpayUrl);
var orchestrator = scope.ServiceProvider.GetRequiredService<TranslationOrchestrator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Starting batch translation for languages: {Languages}",
string.Join(", ", languages));
var results = await orchestrator.TranslateToMultipleLanguagesAsync(languages, force, continueOnError);
var successCount = results.Values.Count(success => success);
var totalCount = results.Count;
logger.LogInformation("Batch translation completed: {SuccessCount}/{TotalCount} successful",
successCount, totalCount);
foreach (var result in results)
{
var status = result.Value ? "✓" : "✗";
logger.LogInformation(" {Status} {Language}", status, result.Key);
}
if (successCount < totalCount)
{
Environment.Exit(1);
}
}, languagesOption, forceOption, continueOnErrorOption, btcpayUrlOption);
return command;
}
private static Command CreateListLanguagesCommand()
{
var command = new Command("list-languages", "List all supported languages");
command.SetHandler(() =>
{
Console.WriteLine("Supported Languages:");
Console.WriteLine("===================");
foreach (var lang in SupportedLanguages.GetAllLanguages().OrderBy(l => l.Name))
{
Console.WriteLine($"{lang.Code,-10} {lang.Name,-20} {lang.NativeName}");
}
});
return command;
}
private static Command CreateStatusCommand(ServiceProvider serviceProvider)
{
var command = new Command("status", "Show translation status for all languages");
command.SetHandler(async () =>
{
using var scope = serviceProvider.CreateScope();
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var fileWriter = scope.ServiceProvider.GetRequiredService<FileWriter>();
var outputDir = configuration["Translation:OutputDirectory"] ??
"translations";
Console.WriteLine("Translation Status:");
Console.WriteLine("==================");
Console.WriteLine($"{"Language",-15} {"Code",-10} {"File Exists",-12} {"Translations",-12}");
Console.WriteLine(new string('-', 55));
foreach (var lang in SupportedLanguages.GetAllLanguages().OrderBy(l => l.Name))
{
var filePath = Path.Combine(outputDir, $"{lang.Name.ToLower()}.json");
var exists = File.Exists(filePath);
var count = 0;
if (exists)
{
try
{
var translations = await fileWriter.LoadExistingBackendTranslationsAsync(filePath);
count = translations.Count;
}
catch
{
// Ignore errors for status check
}
}
var existsText = exists ? "✓" : "✗";
Console.WriteLine($"{lang.Name,-15} {lang.Code,-10} {existsText,-12} {count,-12}");
}
});
return command;
}
private static Command CreateUpdateCommand(ServiceProvider serviceProvider)
{
var languageOption = new Option<string>(
"--language",
"Language code to update (e.g., 'hi', 'es', 'fr')")
{
IsRequired = true
};
var btcpayUrlOption = CreateBTCPayUrlOption();
var command = new Command("update", "Update an existing translation file with new strings")
{
languageOption,
btcpayUrlOption
};
command.SetHandler(async (language, btcpayUrl) =>
{
using var scope = serviceProvider.CreateScope();
ApplyBTCPayUrl(scope.ServiceProvider, btcpayUrl);
var orchestrator = scope.ServiceProvider.GetRequiredService<TranslationOrchestrator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Starting update for language: {Language}", language);
var success = await orchestrator.UpdateLanguageAsync(language);
if (success)
{
logger.LogInformation("Update completed successfully!");
}
else
{
logger.LogError("Update failed!");
Environment.Exit(1);
}
}, languageOption, btcpayUrlOption);
return command;
}
private static Command CreateBatchUpdateCommand(ServiceProvider serviceProvider)
{
var languagesOption = new Option<string[]>(
"--languages",
"Multiple language codes to update (e.g., 'hi es fr')")
{
IsRequired = true,
AllowMultipleArgumentsPerToken = true
};
var continueOnErrorOption = new Option<bool>(
"--continue-on-error",
"Continue processing other languages if one fails")
{
IsRequired = false
};
var btcpayUrlOption = CreateBTCPayUrlOption();
var command = new Command("batch-update", "Update multiple existing translation files with new strings")
{
languagesOption,
continueOnErrorOption,
btcpayUrlOption
};
command.SetHandler(async (languages, continueOnError, btcpayUrl) =>
{
using var scope = serviceProvider.CreateScope();
ApplyBTCPayUrl(scope.ServiceProvider, btcpayUrl);
var orchestrator = scope.ServiceProvider.GetRequiredService<TranslationOrchestrator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Starting batch update for languages: {Languages}",
string.Join(", ", languages));
var results = await orchestrator.UpdateMultipleLanguagesAsync(languages, continueOnError);
var successCount = results.Values.Count(success => success);
var totalCount = results.Count;
logger.LogInformation("Batch update completed: {SuccessCount}/{TotalCount} successful",
successCount, totalCount);
foreach (var result in results)
{
var status = result.Value ? "✓" : "✗";
logger.LogInformation(" {Status} {Language}", status, result.Key);
}
if (successCount < totalCount)
{
Environment.Exit(1);
}
}, languagesOption, continueOnErrorOption, btcpayUrlOption);
return command;
}
private static Command CreateUpdateAllCommand(ServiceProvider serviceProvider)
{
var continueOnErrorOption = new Option<bool>(
"--continue-on-error",
"Continue processing other languages if one fails")
{
IsRequired = false
};
var btcpayUrlOption = CreateBTCPayUrlOption();
var command = new Command("update-all", "Detect and update all existing translation files with new strings")
{
continueOnErrorOption,
btcpayUrlOption
};
command.SetHandler(async (continueOnError, btcpayUrl) =>
{
using var scope = serviceProvider.CreateScope();
ApplyBTCPayUrl(scope.ServiceProvider, btcpayUrl);
var orchestrator = scope.ServiceProvider.GetRequiredService<TranslationOrchestrator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Starting update-all: detecting existing translation files...");
var results = await orchestrator.UpdateAllLanguagesAsync(continueOnError);
if (results.Count == 0)
{
logger.LogError("No translation files found to update");
Environment.Exit(1);
return;
}
var successCount = results.Values.Count(success => success);
var totalCount = results.Count;
logger.LogInformation("Update-all completed: {SuccessCount}/{TotalCount} successful",
successCount, totalCount);
foreach (var result in results)
{
var status = result.Value ? "✓" : "✗";
logger.LogInformation(" {Status} {Language}", status, result.Key);
}
if (successCount < totalCount)
{
Environment.Exit(1);
}
}, continueOnErrorOption, btcpayUrlOption);
return command;
}
private static Command CreateRefreshKeysCommand(ServiceProvider serviceProvider)
{
var sourceFileOption = new Option<string?>(
"--source-file",
"Path to a local BTCPay Translations.Default.cs (or its JSON). When set, source keys are read " +
"from this file instead of downloading from GitHub. Overrides the configured InputFile.")
{
IsRequired = false
};
var languagesOption = new Option<string[]>(
"--languages",
"Optional language codes to limit the refresh to (e.g. 'fr es'). Omit to refresh all files.")
{
IsRequired = false,
AllowMultipleArgumentsPerToken = true
};
var btcpayUrlOption = CreateBTCPayUrlOption();
var command = new Command(
"refresh-keys",
"Insert newly-added English source keys into existing translation files as English placeholders " +
"(insert-only, no AI/OpenRouter, preserves existing lines).")
{
sourceFileOption,
btcpayUrlOption,
languagesOption
};
command.SetHandler(async (sourceFile, btcpayUrl, languages) =>
{
using var scope = serviceProvider.CreateScope();
ApplyBTCPayUrl(scope.ServiceProvider, btcpayUrl);
ApplyInputFile(scope.ServiceProvider, sourceFile);
var orchestrator = scope.ServiceProvider.GetRequiredService<TranslationOrchestrator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
if (!string.IsNullOrWhiteSpace(btcpayUrl) && !string.IsNullOrWhiteSpace(sourceFile))
logger.LogWarning("Both --btcpay-url and --source-file were provided; --btcpay-url takes precedence.");
var codes = languages is { Length: > 0 } ? languages : null;
var result = await orchestrator.RefreshKeysAsync(codes);
logger.LogInformation(
"Refresh completed: {TotalKeysAdded} key(s) added across {FilesProcessed} file(s) ({FilesSkipped} skipped).",
result.TotalKeysAdded, result.FilesProcessed, result.FilesSkipped);
if (result.FilesProcessed == 0)
{
logger.LogError("No translation files found to refresh.");
Environment.Exit(1);
}
}, sourceFileOption, btcpayUrlOption, languagesOption);
return command;
}
private static Command CreateValidatePacksCommand(ServiceProvider serviceProvider)
{
var fixOption = new Option<bool>(
"--fix",
"Automatically fixes suspicious entries by restoring English fallback text or removing hotspot keys.")
{
IsRequired = false
};
var command = new Command(
"validate-packs",
"Validate translation JSON files for suspicious LLM/meta responses and placeholder mismatches")
{
fixOption
};
command.SetHandler(async (fix) =>
{
using var scope = serviceProvider.CreateScope();
var validator = scope.ServiceProvider.GetRequiredService<LanguagePackValidator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Validating translation packs (fix mode: {FixMode})", fix);
var result = await validator.ValidateAsync(fix);
if (fix)
{
// Fix passes are not strictly idempotent: a fix that removes one contamination
// can surface an adjacent contamination that was previously masked. Loop until
// a no-op pass (or an upper bound, to avoid pathological cycles).
const int maxFixPasses = 10;
var pass = 1;
while (result.Issues.Count > 0 && pass < maxFixPasses)
{
pass++;
logger.LogInformation(
"Re-running with fix=true (pass {Pass} of up to {MaxPasses}) - {IssueCount} issues remain",
pass, maxFixPasses, result.Issues.Count);
result = await validator.ValidateAsync(true);
}
logger.LogInformation("Re-running validation after fixes");
result = await validator.ValidateAsync(false);
if (pass == maxFixPasses && result.Issues.Count > 0)
{
logger.LogWarning(
"--fix did not converge after {MaxPasses} passes. {RemainingCount} issue(s) remain and likely require manual review.",
maxFixPasses, result.Issues.Count);
}
}
logger.LogInformation(
"Validation completed: {FilesScanned} files, {EntriesScanned} entries, {IssueCount} issues",
result.FilesScanned,
result.EntriesScanned,
result.Issues.Count);
if (result.Issues.Count > 0)
{
foreach (var issue in result.Issues.Take(200))
{
logger.LogError("{File}: '{Key}' -> {Reason}", issue.FileName, issue.Key, issue.Reason);
}
if (result.Issues.Count > 200)
{
logger.LogError("... {RemainingCount} more issue(s) omitted from log", result.Issues.Count - 200);
}
Environment.Exit(1);
}
}, fixOption);
return command;
}
private static Command CreateGenerateManifestCommand(ServiceProvider serviceProvider)
{
var projectDirectory = ResolveProjectDirectory();
var defaultTranslationPath = Path.Combine(projectDirectory, "..", "translations");
var defaultManifestPath = Path.Combine(projectDirectory, "..", "manifest.json");
var translationPathOption = new Option<string>(
"--translation-path",
"Path to the translations folder. Defaults to <repo-root>/translations.")
{
IsRequired = false
};
translationPathOption.SetDefaultValue(defaultTranslationPath);
var manifestPathOption = new Option<string>(
"--manifest-path",
"Path where manifest.json will be written. Defaults to <repo-root>/manifest.json.")
{
IsRequired = false
};
manifestPathOption.SetDefaultValue(defaultManifestPath);
var command = new Command("generate-manifest", "Generate the manifest.json from translation files")
{
translationPathOption,
manifestPathOption,
};
command.SetHandler(async (translationPath, manifestPath) =>
{
using var scope = serviceProvider.CreateScope();
var generator = scope.ServiceProvider.GetRequiredService<ManifestGenerator>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Starting manifest generation...");
var success = await generator.GenerateManifest(translationPath, manifestPath);
if (!success)
{
logger.LogError("Failed to generate manifest");
Environment.Exit(1);
}
logger.LogInformation("Manifest generated successfully at {manifestPath}", manifestPath);
}, translationPathOption, manifestPathOption);
return command;
}
private static string ResolveProjectDirectory()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory != null)
{
if (File.Exists(Path.Combine(directory.FullName, "BTCPayTranslator.csproj")))
return directory.FullName;
directory = directory.Parent;
}
throw new DirectoryNotFoundException(
"Could not locate the Translator project directory (BTCPayTranslator.csproj) " +
"anywhere above AppContext.BaseDirectory.");
}
}

View File

@ -12,103 +12,80 @@ using Microsoft.Extensions.Logging;
namespace BTCPayTranslator.Services;
public class BaseTranslationService : ITranslationService
public class BaseTranslationService : ITranslationService, IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<BaseTranslationService> _logger;
private readonly string _apiKey;
private readonly string? _apiKey;
private readonly string _model;
private readonly SemaphoreSlim _semaphore;
private readonly TimeProvider _timeProvider;
public string ProviderName => "OpenRouter Fast";
public BaseTranslationService(HttpClient httpClient, IConfiguration configuration, ILogger<BaseTranslationService> logger)
public BaseTranslationService(HttpClient httpClient, IConfiguration configuration, ILogger<BaseTranslationService> logger, TimeProvider? timeProvider = null)
{
_httpClient = httpClient;
_logger = logger;
// Get API key from environment variable
_apiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY") ??
configuration["TranslationService:OpenRouter:ApiKey"] ??
throw new ArgumentException("OpenRouter API key not found. Set OPENROUTER_API_KEY environment variable.");
_model = Environment.GetEnvironmentVariable("OPENROUTER_MODEL") ??
configuration["TranslationService:OpenRouter:Model"] ??
"anthropic/claude-3.5-sonnet";
// Get API key from environment variable. Resolved lazily: construction must not require it,
// so commands that never translate (e.g. refresh-keys) can run without OpenRouter configured.
// The check is enforced at point of use in EnsureApiKeyConfigured().
_apiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY") ??
configuration["TranslationService:OpenRouter:ApiKey"];
_model = Environment.GetEnvironmentVariable("OPENROUTER_MODEL") ??
configuration["TranslationService:OpenRouter:Model"] ??
"anthropic/claude-3.6-sonnet";
// Optimized for speed but still safe
_semaphore = new SemaphoreSlim(5); // 5 concurrent requests max
_semaphore = new SemaphoreSlim(2); // 2 concurrent requests max to avoid rate limits
_timeProvider = timeProvider ?? TimeProvider.System;
_logger.LogInformation("Fast Translation Service initialized - Model: {Model}", _model);
}
private void EnsureApiKeyConfigured()
{
if (string.IsNullOrEmpty(_apiKey))
throw new ArgumentException("OpenRouter API key not found. Set OPENROUTER_API_KEY environment variable.");
}
public async Task<TranslationResponse> TranslateAsync(TranslationRequest request)
{
var maxRetries = 2; // Reduced retries for speed
EnsureApiKeyConfigured();
var maxRetries = 3;
// Only switch into strict-retry prompting when the *prior* attempt produced an LLM
// answer that failed our output validation - not for HTTP errors, HTML-error bodies,
// JSON parse failures, or thrown exceptions, where there was no LLM answer to call
// "invalid" in the next prompt.
var lastFailureWasValidation = false;
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
// Optimized prompt for faster processing
var strictMode = lastFailureWasValidation;
lastFailureWasValidation = false;
var maxTokens = ComputeMaxTokens(request.SourceText);
var requestBody = new
{
model = _model,
messages = new[]
{
new {
role = "system",
content = $@"You are a professional translator for BTCPay Server, a Bitcoin payment processor.
Translate the given English text to {request.TargetLanguage}.
## Context
This text is UI content for a BTCPayServer payment system.
Your goal is to produce clear, professional, and user-friendly translations suitable for financial software.
## Guidelines
Keep technical and cryptocurrency terms in their commonly used form, preferably using transliteration when appropriate.
Retain key terms such as Bitcoin, Lightning, and other crypto-specific terms as-is or transliterated into the target language.
Use a formal tone, appropriate for financial applications.
Keep placeholder variables like {{0}}, {{1}} unchanged.
Preserve HTML tags and special formatting as-is.
Prefer transliteration over translation for standard UI terms unless there is a widely accepted translated equivalent.
Ensure proper sentence structure according to the target language's grammar rules.
## Examples
| English Text | Hindi Translation | Spanish Translation | French Translation |
|--------------|-------------------|---------------------|-------------------|
| ""Hot wallet"" | "" "" | ""Hot wallet"" | ""Portefeuille chaud"" |
| ""Invoice"" | """" | ""Factura"" | ""Facture"" |
| ""Settings"" | ""ि"" | ""Configuración"" | ""Paramètres"" |
| ""Payment successful"" | "" "" | ""Pago exitoso"" | ""Paiement réussi"" |
Edge Cases:
- If the term is widely used as-is in the target language (e.g., Invoice), prefer transliteration in non-English languages.
- If a clear translation exists and is commonly used (e.g., Settings Paramètres in French), use the translated term.
- Do not translate placeholders or variables.
- Do not explain your translation output only the final translated string.
Respond with only the translated text.
No explanations, no additional formatting, no comments."
new {
role = "system",
content = BuildSystemPrompt(request.TargetLanguage, strictMode)
},
new {
role = "user",
new {
role = "user",
content = request.SourceText
}
},
max_tokens = 150, // Reduced for faster response
temperature = 0.0, // More deterministic
top_p = 0.9
max_tokens = maxTokens,
temperature = 0.0
};
var json = JsonSerializer.Serialize(requestBody);
@ -131,10 +108,10 @@ No explanations, no additional formatting, no comments."
{
if (attempt == maxRetries)
{
return new TranslationResponse(request.Key, request.SourceText, false,
return new TranslationResponse(request.Key, string.Empty, false,
$"API error: {response.StatusCode}");
}
await Task.Delay(1000); // Quick retry delay
await Task.Delay(TimeSpan.FromSeconds(1), _timeProvider); // Quick retry delay
continue;
}
@ -143,17 +120,17 @@ No explanations, no additional formatting, no comments."
{
if (attempt == maxRetries)
{
return new TranslationResponse(request.Key, request.SourceText, false,
return new TranslationResponse(request.Key, string.Empty, false,
"HTML error response");
}
await Task.Delay(1000);
await Task.Delay(TimeSpan.FromSeconds(1), _timeProvider);
continue;
}
// Fast JSON parsing
var jsonResponse = JsonSerializer.Deserialize<JsonElement>(responseContent);
if (jsonResponse.TryGetProperty("choices", out var choices) &&
if (jsonResponse.TryGetProperty("choices", out var choices) &&
choices.GetArrayLength() > 0 &&
choices[0].TryGetProperty("message", out var message) &&
message.TryGetProperty("content", out var contentElement))
@ -161,13 +138,32 @@ No explanations, no additional formatting, no comments."
var translatedText = contentElement.GetString()?.Trim();
if (!string.IsNullOrEmpty(translatedText))
{
if (!IsValidTranslationOutput(request.SourceText, translatedText, out var reason))
{
_logger.LogWarning(
"Rejected suspicious translation for key '{Key}' (attempt {Attempt}/{MaxRetries}): {Reason}",
request.Key,
attempt,
maxRetries,
reason);
if (attempt == maxRetries)
{
return new TranslationResponse(request.Key, string.Empty, false, reason);
}
lastFailureWasValidation = true;
await Task.Delay(TimeSpan.FromSeconds(0,800), _timeProvider);
continue;
}
return new TranslationResponse(request.Key, translatedText, true);
}
}
if (attempt == maxRetries)
{
return new TranslationResponse(request.Key, request.SourceText, false,
return new TranslationResponse(request.Key, string.Empty, false,
"No translation returned");
}
}
@ -175,21 +171,22 @@ No explanations, no additional formatting, no comments."
{
if (attempt == maxRetries)
{
return new TranslationResponse(request.Key, request.SourceText, false, ex.Message);
return new TranslationResponse(request.Key, string.Empty, false, ex.Message);
}
await Task.Delay(500); // Quick retry
await Task.Delay(TimeSpan.FromSeconds(0,500), _timeProvider); // Quick retry
}
}
return new TranslationResponse(request.Key, request.SourceText, false, "Translation failed");
return new TranslationResponse(request.Key, string.Empty, false, "Translation failed");
}
public async Task<BatchTranslationResponse> TranslateBatchAsync(BatchTranslationRequest request)
{
EnsureApiKeyConfigured();
var startTime = DateTime.UtcNow;
var results = new List<TranslationResponse>();
_logger.LogInformation("Starting FAST batch translation of {Count} items to {Language} with 5 concurrent requests",
_logger.LogInformation("Starting FAST batch translation of {Count} items to {Language} with 2 concurrent requests",
request.Items.Count, request.TargetLanguage);
// Process in parallel chunks for speed
@ -211,7 +208,7 @@ No explanations, no additional formatting, no comments."
);
var result = await TranslateAsync(translationRequest);
// Log progress every 10 items
var currentCount = Interlocked.Increment(ref completedCount);
if (currentCount % 10 == 0)
@ -225,7 +222,7 @@ No explanations, no additional formatting, no comments."
{
_semaphore.Release();
// Small delay to avoid overwhelming the API
await Task.Delay(100); // Very short delay for speed
await Task.Delay(TimeSpan.FromSeconds(0,300), _timeProvider); // Increased delay to avoid rate limits
}
});
@ -235,7 +232,7 @@ No explanations, no additional formatting, no comments."
// Brief pause between chunks
if (chunks.Count() > 1)
{
await Task.Delay(500); // Half second between chunks
await Task.Delay(TimeSpan.FromSeconds(0,500), _timeProvider);; // Half second between chunks
}
}
@ -243,14 +240,14 @@ No explanations, no additional formatting, no comments."
var successCount = results.Count(r => r.Success);
var failureCount = results.Count - successCount;
_logger.LogInformation("FAST batch translation completed: {SuccessCount}/{TotalCount} successful in {Duration:mm\\:ss}",
_logger.LogInformation("FAST batch translation completed: {SuccessCount}/{TotalCount} successful in {Duration:mm\\:ss}",
successCount, results.Count, duration);
// Log some sample translations
var successfulTranslations = results.Where(r => r.Success).Take(5);
foreach (var translation in successfulTranslations)
{
_logger.LogInformation("Sample: '{Key}' -> '{Translation}'",
_logger.LogInformation("Sample: '{Key}' -> '{Translation}'",
translation.Key, translation.TranslatedText);
}
@ -272,6 +269,84 @@ No explanations, no additional formatting, no comments."
}
}
private static string BuildSystemPrompt(string targetLanguage, bool strictMode)
{
var strictRules = strictMode
? "\n\nSTRICT RETRY MODE: Your previous answer was invalid. Do not ask for more input. Return only the final translated UI string."
: string.Empty;
return $@"You are translating a single BTCPay Server UI string to {targetLanguage}.
Rules:
- Translate the full meaning faithfully. Do not summarize, simplify, or omit details.
- Keep the original tone and intent (for example, command labels remain short/imperative).
- Preserve placeholders exactly (examples: {{0}}, {{OrderId}}, {{InvoiceId}}).
- Preserve HTML tags/entities, punctuation, casing, and line breaks exactly.
- Keep technical/product names and standard crypto terms in English when commonly used.
- Do not translate to English unless the source is already English-only technical jargon.
- Never ask for more text or context.
- Never mention instructions, prompts, role, AI, or translation process.
- Output only the translated text for this one string, with no quotes or extra commentary.
Return only the translated string.{strictRules}";
}
private int ComputeMaxTokens(string sourceText)
{
if (string.IsNullOrEmpty(sourceText))
return 220;
// Approximate source tokens and allow expansion for longer target-language strings.
// Upper bound raised to 1800 so verbose expanding languages (German, Hungarian,
// Finnish, Russian, etc) do not get truncated mid-output on long sources - truncation
// would trip the placeholder-matching output check on retry and waste an attempt.
var estimatedTokens = (int)Math.Ceiling((sourceText.Length / 4.0) * 2.0);
var bounded = Math.Clamp(estimatedTokens, 220, 1800);
if (bounded != estimatedTokens)
{
_logger.LogDebug(
"ComputeMaxTokens clamped estimate {Estimated} to {Bounded} for source length {Length}",
estimatedTokens,
bounded,
sourceText.Length);
}
return bounded;
}
private static bool IsValidTranslationOutput(string sourceText, string translatedText, out string reason)
{
if (TranslationValidationRules.IsSuspiciousMetaResponse(translatedText))
{
reason = "Suspicious LLM/meta-response content";
return false;
}
if (!TranslationValidationRules.HasMatchingPlaceholders(sourceText, translatedText))
{
reason = "Placeholder/token mismatch";
return false;
}
if (TranslationValidationRules.IsLikelySentenceFallback(sourceText, translatedText))
{
reason = "Suspicious source fallback (sentence-like translation equals source text)";
return false;
}
// Short hotspot keys (Confirm, Continue, Retry, Yes, Copy Code, ...) that round-trip
// unchanged are the same contamination class the reactive validator in
// LanguagePackValidator catches. Reject them at generation-time so they do not land
// in locale files in the first place.
if (TranslationValidationRules.IsShortKeyEnglishFallback(sourceText, translatedText))
{
reason = "Common UI label left untranslated (translation equals English source)";
return false;
}
reason = string.Empty;
return true;
}
public void Dispose()
{
_semaphore?.Dispose();

View File

@ -0,0 +1,335 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using BTCPayTranslator.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayTranslator.Services;
public class FileWriter
{
private readonly ILogger<FileWriter> _logger;
private readonly JsonSerializerSettings _jsonSettings;
public FileWriter(ILogger<FileWriter> logger)
{
_logger = logger;
_jsonSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented,
StringEscapeHandling = StringEscapeHandling.EscapeNonAscii
};
}
public async Task WriteCheckoutTranslationFileAsync(
string outputPath,
LanguageInfo languageInfo,
Dictionary<string, string> translations)
{
try
{
// Create the translation file structure
var translationFile = new JObject
{
["NOTICE_WARN"] = "THIS CODE HAS BEEN AUTOMATICALLY GENERATED FROM TRANSIFEX, IF YOU WISH TO HELP TRANSLATION COME ON THE SLACK https://chat.btcpayserver.org/ TO REQUEST PERMISSION TO https://www.transifex.com/btcpayserver/btcpayserver/",
["code"] = languageInfo.Code,
["currentLanguage"] = languageInfo.NativeName
};
// Add all translations
foreach (var translation in translations.OrderBy(t => t.Key, StringComparer.Ordinal))
{
translationFile[translation.Key] = translation.Value;
}
// Ensure output directory exists
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
_logger.LogInformation("Created directory: {Directory}", directory);
}
// Write the file
var json = translationFile.ToString(Formatting.Indented);
await File.WriteAllTextAsync(outputPath, json);
_logger.LogInformation("Successfully wrote {Count} translations to {OutputPath}",
translations.Count, outputPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error writing translation file to {OutputPath}", outputPath);
throw;
}
}
public async Task WriteBackendTranslationFileAsync(
string outputPath,
LanguageInfo languageInfo,
Dictionary<string, string> translations)
{
try
{
// Create the backend translation file structure (simple JSON)
var translationFile = new JObject();
// Add all translations
foreach (var translation in translations.OrderBy(t => t.Key, StringComparer.Ordinal))
{
translationFile[translation.Key] = translation.Value;
}
// Ensure output directory exists
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
_logger.LogInformation("Created directory: {Directory}", directory);
}
// Write the file
var json = translationFile.ToString(Formatting.Indented);
await File.WriteAllTextAsync(outputPath, json);
_logger.LogInformation("Successfully wrote {Count} backend translations to {OutputPath}",
translations.Count, outputPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error writing backend translation file to {OutputPath}", outputPath);
throw;
}
}
public async Task<Dictionary<string, string>> LoadExistingBackendTranslationsAsync(string filePath)
{
try
{
if (!File.Exists(filePath))
{
return new Dictionary<string, string>();
}
var content = await File.ReadAllTextAsync(filePath);
var jsonObject = JObject.Parse(content);
var translations = new Dictionary<string, string>();
foreach (var property in jsonObject.Properties())
{
var value = property.Value?.ToString() ?? "";
if (!string.IsNullOrEmpty(value))
{
translations[property.Name] = value;
}
}
_logger.LogInformation("Loaded {Count} existing translations from {FilePath}",
translations.Count, filePath);
return translations;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading existing translations from {FilePath}", filePath);
return new Dictionary<string, string>();
}
}
public async Task WriteSummaryReportAsync(
string outputPath,
string language,
BatchTranslationResponse response,
Dictionary<string, string> finalTranslations)
{
try
{
var report = new
{
Language = language,
Timestamp = DateTime.UtcNow,
Translation = new
{
TotalItems = response.Results.Count,
SuccessfulTranslations = response.SuccessCount,
FailedTranslations = response.FailureCount,
Duration = response.Duration.ToString(@"hh\:mm\:ss"),
SuccessRate = $"{(double)response.SuccessCount / response.Results.Count * 100:F1}%"
},
Output = new
{
FinalTranslationCount = finalTranslations.Count,
OutputFile = outputPath
},
Failures = response.Results
.Where(r => !r.Success)
.Select(r => new { r.Key, r.Error })
.ToArray()
};
var reportPath = Path.ChangeExtension(outputPath, ".report.json");
var json = JsonConvert.SerializeObject(report, _jsonSettings);
await File.WriteAllTextAsync(reportPath, json);
_logger.LogInformation("Translation summary report written to {ReportPath}", reportPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error writing summary report");
}
}
// Same comparer the writer uses for ordering (OrderBy(t => t.Key) -> Comparer<string>.Default).
private static readonly IComparer<string> WriterKeyComparer = StringComparer.Ordinal;
private static readonly Regex TrailingCommaRegex = new(@",(\s*)$", RegexOptions.Compiled);
/// <summary>
/// Inserts the keys from <paramref name="source"/> that are missing from <paramref name="filePath"/>
/// as new JSON entries (value = the source value), preserving every existing line byte-for-byte.
/// This is the insert-only, no-AI "refresh" path. Returns the number of keys added (0 if the file is
/// missing, already up to date, or could not be rewritten safely).
/// </summary>
public async Task<int> InsertMissingKeysAsync(string filePath, IReadOnlyDictionary<string, string> source)
{
if (!File.Exists(filePath))
{
_logger.LogWarning("Translation file not found, skipping: {FilePath}", filePath);
return 0;
}
var raw = await File.ReadAllTextAsync(filePath);
List<string> existingKeys;
try
{
existingKeys = JObject.Parse(raw).Properties().Select(p => p.Name).ToList();
}
catch (JsonReaderException ex)
{
_logger.LogError(ex, "Invalid JSON, skipping: {FilePath}", filePath);
return 0;
}
var rebuilt = BuildRebuilt(raw, existingKeys, source, out var added);
if (rebuilt is null)
{
_logger.LogWarning("Could not safely insert keys (structure mismatch), skipping: {FilePath}", filePath);
return 0;
}
if (added == 0)
return 0;
// Validation gate: never write a corrupt or lossy file.
JObject check;
try
{
check = JObject.Parse(rebuilt);
}
catch (JsonReaderException ex)
{
_logger.LogError(ex, "Refusing to write {FilePath}: rebuilt content is not valid JSON", filePath);
return 0;
}
var finalCount = check.Properties().Count();
var allSourcePresent = source.Keys.All(k => check.ContainsKey(k));
if (finalCount != existingKeys.Count + added || !allSourcePresent)
{
_logger.LogError(
"Refusing to write {FilePath}: validation failed (final={Final}, expected={Expected}, allSourcePresent={AllPresent})",
filePath, finalCount, existingKeys.Count + added, allSourcePresent);
return 0;
}
await File.WriteAllTextAsync(filePath, rebuilt, new UTF8Encoding(false));
_logger.LogInformation("Inserted {Added} new key(s) into {FilePath}", added, filePath);
return added;
}
// Pure (no IO). Returns the rebuilt file text with missing keys spliced in, or null if the file
// structure is not what we expect (in which case the caller skips it without writing).
private static string? BuildRebuilt(
string raw,
IReadOnlyList<string> existingKeys,
IReadOnlyDictionary<string, string> source,
out int addedCount)
{
addedCount = 0;
var newline = raw.Contains("\r\n") ? "\r\n" : "\n";
var parts = raw.Split(new[] { newline }, StringSplitOptions.None).ToList();
var trailingNewline = parts.Count > 0 && parts[^1].Length == 0;
if (trailingNewline)
parts.RemoveAt(parts.Count - 1);
if (parts.Count < 2 || parts[0].Trim() != "{" || parts[^1].Trim() != "}")
return null;
var entryLines = parts.GetRange(1, parts.Count - 2);
if (entryLines.Count != existingKeys.Count)
return null;
var existingSet = new HashSet<string>(existingKeys, StringComparer.Ordinal);
var missing = source.Keys.Where(k => !existingSet.Contains(k)).ToList();
if (missing.Count == 0)
return raw;
// For each missing key, find the existing key it should follow in canonical (writer) order.
const string topAnchor = "TOP";
var perAnchor = new Dictionary<string, List<string>>(StringComparer.Ordinal);
var anchor = topAnchor;
foreach (var key in existingKeys.Concat(missing).OrderBy(k => k, WriterKeyComparer))
{
if (existingSet.Contains(key))
{
anchor = key;
continue;
}
if (!perAnchor.TryGetValue(anchor, out var list))
perAnchor[anchor] = list = new List<string>();
list.Add(key);
}
var units = new List<string>(entryLines.Count + missing.Count);
if (perAnchor.TryGetValue(topAnchor, out var topKeys))
units.AddRange(topKeys.Select(k => RenderEntryLine(k, source[k])));
for (var i = 0; i < existingKeys.Count; i++)
{
units.Add(entryLines[i]);
if (perAnchor.TryGetValue(existingKeys[i], out var afterKeys))
units.AddRange(afterKeys.Select(k => RenderEntryLine(k, source[k])));
}
for (var i = 0; i < units.Count; i++)
units[i] = SetTrailingComma(units[i], needComma: i != units.Count - 1);
addedCount = missing.Count;
return parts[0] + newline + string.Join(newline, units) + newline + parts[^1] + (trailingNewline ? newline : "");
}
// Adds/removes a single trailing comma only when the required state differs, preserving any
// trailing whitespace (so existing lines stay byte-identical unless their comma must change).
private static string SetTrailingComma(string line, bool needComma)
{
var hasComma = TrailingCommaRegex.IsMatch(line);
if (needComma == hasComma)
return line;
return needComma ? line + "," : TrailingCommaRegex.Replace(line, "$1");
}
// Renders a new entry line with the same indentation and (default) escaping as the existing files.
// Newtonsoft's default StringEscapeHandling escapes only " \ and control chars - it leaves
// < > & and non-ASCII raw, which matches how these files are written. Do NOT use _jsonSettings here
// (its EscapeNonAscii would corrupt non-ASCII placeholders).
private static string RenderEntryLine(string key, string value) =>
" " + new JValue(key).ToString(Formatting.None) + ": " + new JValue(value).ToString(Formatting.None);
}

View File

@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayTranslator.Services;
public sealed record ValidationIssue(string FileName, string Key, string Reason);
public sealed record ValidationResult(
int FilesScanned,
int EntriesScanned,
List<ValidationIssue> Issues);
public class LanguagePackValidator
{
private readonly IConfiguration _configuration;
private readonly ILogger<LanguagePackValidator> _logger;
public LanguagePackValidator(IConfiguration configuration, ILogger<LanguagePackValidator> logger)
{
_configuration = configuration;
_logger = logger;
}
public async Task<ValidationResult> ValidateAsync(bool fix)
{
var outputDirectory = _configuration["Translation:OutputDirectory"] ?? "translations";
if (!Directory.Exists(outputDirectory))
{
return new ValidationResult(0, 0, new List<ValidationIssue>
{
new("<none>", "<none>", $"Translation directory '{outputDirectory}' does not exist")
});
}
var files = Directory.GetFiles(outputDirectory, "*.json").OrderBy(path => path).ToList();
var issues = new List<ValidationIssue>();
var totalEntries = 0;
foreach (var filePath in files)
{
JObject json;
var fileChanged = false;
try
{
var content = await File.ReadAllTextAsync(filePath);
json = JObject.Parse(content);
}
catch (JsonReaderException ex)
{
var fileName = Path.GetFileName(filePath);
issues.Add(new ValidationIssue(fileName, "<file>", $"Invalid JSON: {ex.Message}"));
_logger.LogError(ex, "Invalid JSON in translation file {FileName}", fileName);
continue;
}
catch (IOException ex)
{
var fileName = Path.GetFileName(filePath);
issues.Add(new ValidationIssue(fileName, "<file>", $"I/O error while reading file: {ex.Message}"));
_logger.LogError(ex, "I/O error while reading translation file {FileName}", fileName);
continue;
}
foreach (var property in json.Properties().ToList())
{
var key = property.Name;
var value = property.Value?.ToString() ?? string.Empty;
if (key.Equals("_maintainer", StringComparison.Ordinal))
{
var maintainerValue = property.Value?.Type == JTokenType.Null ? null : value;
if (!TranslationValidationRules.IsValidMaintainerValue(maintainerValue))
{
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key,
"Invalid _maintainer value (expected '<display name or handle>|<https URL>')"));
}
continue;
}
totalEntries++;
if (TranslationValidationRules.IsSuspiciousMetaResponse(value))
{
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key, "Suspicious LLM/meta-response content"));
if (fix)
{
fileChanged |= ApplyFix(property, key, value);
}
continue;
}
if (TranslationValidationRules.IsLikelySentenceFallback(key, value))
{
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key, "Suspicious source fallback (sentence-like value equals source key)"));
if (fix)
{
fileChanged |= ApplyFix(property, key, value, sentenceFallback: true);
}
continue;
}
if (!TranslationValidationRules.HasMatchingPlaceholders(key, value))
{
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key, "Placeholder/token mismatch between source key and translation"));
if (fix)
{
fileChanged |= ApplyFix(property, key, value);
}
continue;
}
if (TranslationValidationRules.IsShortKeyEnglishFallback(key, value))
{
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key, "Common UI label left untranslated (value equals English key)"));
if (fix)
{
fileChanged |= ApplyFix(property, key, value);
}
continue;
}
if (!TranslationValidationRules.HasMatchingHtmlTags(key, value))
{
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key,
"Structural HTML tag mismatch between source key and translation"));
// Auto-fix is intentionally skipped here. Maintainer needs to re-anchor the markup by hand.
}
}
if (fix && fileChanged)
{
await File.WriteAllTextAsync(filePath, json.ToString(Formatting.Indented));
_logger.LogInformation("Fixed suspicious/mismatched entries in {FileName}", Path.GetFileName(filePath));
}
}
return new ValidationResult(files.Count, totalEntries, issues);
}
// Applies a fix to a single contaminated JSON property.
// Returns true when the property was modified (removed or rewritten) so the caller
// can track whether the enclosing file needs to be rewritten.
private static bool ApplyFix(JProperty property, string key, string currentValue, bool sentenceFallback = false)
{
if (TranslationValidationRules.IsShortKeyFallbackHotspot(key))
{
property.Remove();
return true;
}
if (sentenceFallback && string.Equals(currentValue, key, StringComparison.Ordinal))
{
// Avoid a no-op for sentence fallbacks: remove the entry so runtime falls back cleanly.
property.Remove();
return true;
}
property.Value = key;
return true;
}
}

View File

@ -0,0 +1,171 @@
using System;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using BTCPayTranslator.Models;
using Microsoft.Extensions.Logging;
namespace BTCPayTranslator.Services;
public class ManifestGenerator
{
private static string FormatUtcTimestamp(DateTime utc) =>
utc.ToString("yyyy-MM-ddTHH:mm:ssZ");
private readonly ILogger<ManifestGenerator> _logger;
public ManifestGenerator(ILogger<ManifestGenerator> logger)
{
_logger = logger;
}
private IEnumerable<string>? GetTranslationFiles(string translationDirectoryPath)
{
try
{
return Directory.GetFiles(translationDirectoryPath, "*.json");
}
catch (Exception ex)
{
_logger.LogError(ex, "Couldn't find translation files in {Directory}", translationDirectoryPath);
throw new Exception("Couldn't find translation files", ex);
}
}
private async Task<string?> HashFiles(string filePath)
{
using var sha256 = SHA256.Create();
try
{
await using var stream = File.OpenRead(filePath);
var hashBytes = await sha256.ComputeHashAsync(stream);
return Convert.ToHexString(hashBytes).ToLower();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to hash file {FilePath}", filePath);
throw new Exception("Couldn't hash translation file", ex);
}
}
private async Task<string?> GetMaintainer(string filePath)
{
try
{
var json = await File.ReadAllTextAsync(filePath);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.TryGetProperty("_maintainer", out var maintainer))
{
return maintainer.GetString();
}
_logger.LogWarning("Missing _maintainer field in {FilePath}", filePath);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to read maintainer from {FilePath}", filePath);
throw new Exception("Couldn't read maintainer from translation file", ex);
}
}
private async Task<ManifestEntry> BuildEntry(string filePath, ManifestEntry? existingEntry, string runUpdatedAt)
{
try
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
var result = SupportedLanguages.GetLanguageInfoByName(fileName);
if (!result.HasValue)
{
_logger.LogError("No language info mapping found for translation file {FileName}", fileName);
throw new Exception($"No language info found for {fileName}");
}
var (code, langInfo) = result.Value;
var hashedFile = await HashFiles(filePath);
if (hashedFile == null)
{
_logger.LogError("Skipping {FilePath} because hash generation failed", filePath);
throw new Exception($"Hash generation failed for {filePath}");
}
var maintainer = await GetMaintainer(filePath);
var updatedAt = existingEntry?.Sha == hashedFile ? existingEntry!.Updated : runUpdatedAt;
var entry = new ManifestEntry(
Code: code,
Bcp47: langInfo.Code,
Name: langInfo.Name,
Native: langInfo.NativeName,
File: "translations/" + fileName + ".json",
Sha: hashedFile,
Maintainer: maintainer,
Updated: updatedAt);
return entry;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to build manifest entry for {FilePath}", filePath);
throw new Exception("Couldn't build manifest entry", ex);
}
}
public async Task<bool> GenerateManifest(string translationDirectoryPath, string manifestOutputPath)
{
try
{
_logger.LogInformation("Starting manifest generation");
var runUpdatedAt = FormatUtcTimestamp(DateTime.UtcNow);
Manifest? existingManifest = null;
if (File.Exists(manifestOutputPath))
{
var existingJson = await File.ReadAllTextAsync(manifestOutputPath);
existingManifest = JsonSerializer.Deserialize<Manifest>(existingJson);
}
var files = GetTranslationFiles(translationDirectoryPath)?.OrderBy(f=> f).ToArray();
if (files == null || files.Length == 0)
{
_logger.LogError("No translation files found to generate manifest");
return false;
}
var entries = new List<ManifestEntry>();
foreach (var file in files)
{
var existingEntry = existingManifest?.Languages
.FirstOrDefault(e => e.File == "translations/" + Path.GetFileName(file));
var entry = await BuildEntry(file, existingEntry, runUpdatedAt);
entries.Add(entry);
}
var manifest = new Manifest(entries, Redirect: null);
var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
await File.WriteAllTextAsync(manifestOutputPath, manifestJson);
_logger.LogInformation("Manifest generated with {EntryCount}/{FileCount} entries at {ManifestPath}", entries.Count, files.Length, manifestOutputPath);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Manifest generation failed");
return false;
}
}
}

View File

@ -2,12 +2,10 @@ using System.Threading;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayTranslator.Services;
@ -28,6 +26,45 @@ public class TranslationExtractor
Directory.CreateDirectory(_cacheDirectory);
}
public async Task<Dictionary<string, string>> ExtractFromBTCPayServerAsync(string btcpayUrl)
{
var url = btcpayUrl.TrimEnd('/') + "/cheat/translations/default-en";
try
{
_logger.LogInformation("Fetching translations from BTCPay Server at {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
throw new InvalidOperationException(
$"The /cheat/translations/default-en endpoint was not found. " +
$"Make sure BTCPay Server is running with cheatmode=true (debug mode) at {btcpayUrl}.");
response.EnsureSuccessStatusCode();
}
var json = await response.Content.ReadAsStringAsync();
var jsonObject = JObject.Parse(json);
var translations = new Dictionary<string, string>();
foreach (var property in jsonObject.Properties())
{
var value = property.Value?.ToString() ?? "";
translations[property.Name] = string.IsNullOrEmpty(value) ? property.Name : value;
}
_logger.LogInformation("Fetched {Count} translations from BTCPay Server", translations.Count);
return translations;
}
catch (HttpRequestException ex)
{
throw new InvalidOperationException(
$"Could not connect to BTCPay Server at {btcpayUrl}. " +
$"Make sure it is running in debug mode (cheatmode=true). Error: {ex.Message}", ex);
}
}
public async Task<Dictionary<string, string>> ExtractFromDefaultFileAsync(string filePathOrUrl)
{
try
@ -69,14 +106,13 @@ public class TranslationExtractor
var key = property.Name;
var value = property.Value?.ToString() ?? "";
// Skip empty translations (they default to the key itself)
if (!string.IsNullOrEmpty(value))
{
translations[key] = value;
}
else
{
translations[key] = key; // Use key as default value
translations[key] = key;
}
}

View File

@ -0,0 +1,647 @@
using System.Threading;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BTCPayTranslator.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace BTCPayTranslator.Services;
public class TranslationOrchestrator
{
private readonly ITranslationService _translationService;
private readonly TranslationExtractor _extractor;
private readonly FileWriter _fileWriter;
private readonly IConfiguration _configuration;
private readonly ILogger<TranslationOrchestrator> _logger;
public TranslationOrchestrator(
ITranslationService translationService,
TranslationExtractor extractor,
FileWriter fileWriter,
IConfiguration configuration,
ILogger<TranslationOrchestrator> logger)
{
_translationService = translationService;
_extractor = extractor;
_fileWriter = fileWriter;
_configuration = configuration;
_logger = logger;
}
public async Task<Dictionary<string, string>> GetSourceTranslationsAsync()
{
var btcpayUrl = _configuration["Translation:BTCPayUrl"];
if (!string.IsNullOrWhiteSpace(btcpayUrl))
{
_logger.LogInformation("BTCPay Server URL configured — fetching translations from {Url}", btcpayUrl);
return await _extractor.ExtractFromBTCPayServerAsync(btcpayUrl);
}
var inputFile = _configuration["Translation:InputFile"] ??
"https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/Plugins/Translations/Translations.Default.cs";
_logger.LogInformation("Fetching translations from file/URL: {Source}", inputFile);
return await _extractor.ExtractFromDefaultFileAsync(inputFile);
}
public async Task<bool> TranslateToLanguageAsync(string languageCode, bool forceRetranslate = false)
{
try
{
var languageInfo = SupportedLanguages.GetLanguageInfo(languageCode);
if (languageInfo == null)
{
_logger.LogError("Unsupported language code: {LanguageCode}", languageCode);
return false;
}
_logger.LogInformation("Starting translation to {Language} ({NativeName})",
languageInfo.Name, languageInfo.NativeName);
var sourceTranslations = await GetSourceTranslationsAsync();
// Determine output paths
var outputDir = _configuration["Translation:OutputDirectory"] ??
"../BTCPayServer/translations";
var outputPath = Path.Combine(outputDir, $"{languageInfo.Name.ToLower()}.json");
// Load existing translations if they exist
var existingTranslations = await _fileWriter.LoadExistingBackendTranslationsAsync(outputPath);
// Determine what needs to be translated
Dictionary<string, string> translationsToProcess;
if (forceRetranslate)
{
translationsToProcess = sourceTranslations;
_logger.LogInformation("Force retranslate mode: processing all {Count} translations",
sourceTranslations.Count);
}
else
{
translationsToProcess = _extractor.GetTranslationsToUpdate(sourceTranslations, existingTranslations);
if (translationsToProcess.Count == 0)
{
_logger.LogInformation("No new translations needed for {Language}", languageInfo.Name);
return true;
}
}
// Prepare translation requests for ALL translations
var batchSize = _configuration.GetValue<int>("Translation:BatchSize", 50);
var requests = translationsToProcess
.Select(t => new TranslationRequest(t.Key, t.Value, languageInfo.Name))
.ToList();
// Process translations in batches
var allResults = new List<TranslationResponse>();
for (int i = 0; i < requests.Count; i += batchSize)
{
var batch = requests.Skip(i).Take(batchSize).ToList();
_logger.LogInformation("Processing batch {CurrentBatch}/{TotalBatches} ({Count} items)",
(i / batchSize) + 1, (int)Math.Ceiling((double)requests.Count / batchSize), batch.Count);
var batchRequest = new BatchTranslationRequest(batch, languageInfo.Name, languageInfo.NativeName);
var batchResponse = await _translationService.TranslateBatchAsync(batchRequest);
allResults.AddRange(batchResponse.Results);
// Add delay between batches to be respectful to the API
if (i + batchSize < requests.Count)
{
var delay = _configuration.GetValue<int>("Translation:DelayBetweenRequests", 1000);
await Task.Delay(delay);
}
}
// Process results
var newTranslations = allResults
.Where(r => r.Success)
.ToDictionary(r => r.Key, r => r.TranslatedText);
var finalTranslations = _extractor.MergeTranslations(existingTranslations, newTranslations);
// Write backend translation file (simple JSON format)
await _fileWriter.WriteBackendTranslationFileAsync(
outputPath, languageInfo, finalTranslations);
// Write summary report
var summaryResponse = new BatchTranslationResponse(
allResults,
allResults.Count(r => r.Success),
allResults.Count(r => !r.Success),
TimeSpan.Zero);
await _fileWriter.WriteSummaryReportAsync(
outputPath, languageInfo.Name, summaryResponse, finalTranslations);
var successRate = (double)newTranslations.Count / translationsToProcess.Count * 100;
_logger.LogInformation(
"Translation completed for {Language}: {SuccessCount}/{TotalCount} successful ({SuccessRate:F1}%)",
languageInfo.Name, newTranslations.Count, translationsToProcess.Count, successRate);
return successRate > 80; // Consider successful if >80% success rate
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during translation process for language {LanguageCode}", languageCode);
return false;
}
}
public async Task<Dictionary<string, bool>> TranslateToMultipleLanguagesAsync(
IEnumerable<string> languageCodes,
bool forceRetranslate = false,
bool continueOnError = true)
{
var results = new Dictionary<string, bool>();
foreach (var languageCode in languageCodes)
{
try
{
_logger.LogInformation("Starting translation for language: {LanguageCode}", languageCode);
var success = await TranslateToLanguageAsync(languageCode, forceRetranslate);
results[languageCode] = success;
if (!success && !continueOnError)
{
_logger.LogWarning("Translation failed for {LanguageCode}, stopping batch process", languageCode);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error translating language {LanguageCode}", languageCode);
results[languageCode] = false;
if (!continueOnError)
{
break;
}
}
}
var totalLanguages = results.Count;
var successfulLanguages = results.Values.Count(success => success);
_logger.LogInformation("Batch translation completed: {SuccessCount}/{TotalCount} languages successful",
successfulLanguages, totalLanguages);
return results;
}
public async Task<bool> UpdateLanguageAsync(string languageCode)
{
try
{
var languageInfo = SupportedLanguages.GetLanguageInfo(languageCode);
if (languageInfo == null)
{
_logger.LogError("Unsupported language code: {LanguageCode}", languageCode);
return false;
}
_logger.LogInformation("Starting update for {Language} ({NativeName})",
languageInfo.Name, languageInfo.NativeName);
var sourceTranslations = await GetSourceTranslationsAsync();
_logger.LogInformation("Found {Count} strings in source", sourceTranslations.Count);
// Determine output path
var outputDir = _configuration["Translation:OutputDirectory"] ?? "translations";
var outputPath = Path.Combine(outputDir, $"{languageInfo.Name.ToLower()}.json");
// Load existing translations
if (!File.Exists(outputPath))
{
_logger.LogError("Translation file not found: {OutputPath}. Use 'translate' command to create it first.", outputPath);
return false;
}
var existingTranslations = await _fileWriter.LoadExistingBackendTranslationsAsync(outputPath);
_logger.LogInformation("Loaded {Count} existing translations", existingTranslations.Count);
// Find what's new, what's deleted, and what's unchanged
var newKeys = sourceTranslations.Keys.Except(existingTranslations.Keys).ToList();
var deletedKeys = existingTranslations.Keys.Except(sourceTranslations.Keys).ToList();
var unchangedKeys = existingTranslations.Keys.Intersect(sourceTranslations.Keys).ToList();
_logger.LogInformation("Analysis: {NewCount} new strings, {DeletedCount} deleted strings, {UnchangedCount} unchanged strings",
newKeys.Count, deletedKeys.Count, unchangedKeys.Count);
if (newKeys.Count == 0 && deletedKeys.Count == 0)
{
_logger.LogInformation("No updates needed. Translation file is up to date.");
return true;
}
// Translate only new strings
var translationsToProcess = newKeys.ToDictionary(k => k, k => sourceTranslations[k]);
if (translationsToProcess.Count > 0)
{
_logger.LogInformation("Translating {Count} new strings...", translationsToProcess.Count);
var batchSize = _configuration.GetValue<int>("Translation:BatchSize", 50);
var requests = translationsToProcess
.Select(t => new TranslationRequest(t.Key, t.Value, languageInfo.Name))
.ToList();
var allResults = new List<TranslationResponse>();
for (int i = 0; i < requests.Count; i += batchSize)
{
var batch = requests.Skip(i).Take(batchSize).ToList();
_logger.LogInformation("Processing batch {CurrentBatch}/{TotalBatches} ({Count} items)",
(i / batchSize) + 1, (int)Math.Ceiling((double)requests.Count / batchSize), batch.Count);
var batchRequest = new BatchTranslationRequest(batch, languageInfo.Name, languageInfo.NativeName);
var batchResponse = await _translationService.TranslateBatchAsync(batchRequest);
allResults.AddRange(batchResponse.Results);
if (i + batchSize < requests.Count)
{
var delay = _configuration.GetValue<int>("Translation:DelayBetweenRequests", 1000);
await Task.Delay(delay);
}
}
var newTranslations = allResults
.Where(r => r.Success)
.ToDictionary(r => r.Key, r => r.TranslatedText);
_logger.LogInformation("Successfully translated {SuccessCount}/{TotalCount} new strings",
newTranslations.Count, translationsToProcess.Count);
// Merge new translations with existing ones
foreach (var newTranslation in newTranslations)
{
existingTranslations[newTranslation.Key] = newTranslation.Value;
}
}
// Remove deleted keys
foreach (var deletedKey in deletedKeys)
{
existingTranslations.Remove(deletedKey);
_logger.LogDebug("Removed deleted key: {Key}", deletedKey);
}
// Rebuild the final dictionary in the same order as source
var finalTranslations = new Dictionary<string, string>();
foreach (var sourceKey in sourceTranslations.Keys)
{
if (existingTranslations.ContainsKey(sourceKey))
{
finalTranslations[sourceKey] = existingTranslations[sourceKey];
}
}
// Write updated translation file
await _fileWriter.WriteBackendTranslationFileAsync(
outputPath, languageInfo, finalTranslations);
_logger.LogInformation(
"Update completed for {Language}: {TotalCount} total strings ({NewCount} added, {DeletedCount} removed)",
languageInfo.Name, finalTranslations.Count, newKeys.Count, deletedKeys.Count);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during update process for language {LanguageCode}", languageCode);
return false;
}
}
public async Task<Dictionary<string, bool>> UpdateMultipleLanguagesAsync(
IEnumerable<string> languageCodes,
bool continueOnError = true)
{
var results = new Dictionary<string, bool>();
foreach (var languageCode in languageCodes)
{
try
{
_logger.LogInformation("Starting update for language: {LanguageCode}", languageCode);
var success = await UpdateLanguageAsync(languageCode);
results[languageCode] = success;
if (!success && !continueOnError)
{
_logger.LogWarning("Update failed for {LanguageCode}, stopping batch process", languageCode);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating language {LanguageCode}", languageCode);
results[languageCode] = false;
if (!continueOnError)
{
break;
}
}
}
var totalLanguages = results.Count;
var successfulLanguages = results.Values.Count(success => success);
_logger.LogInformation("Batch update completed: {SuccessCount}/{TotalCount} languages successful",
successfulLanguages, totalLanguages);
return results;
}
public async Task<Dictionary<string, bool>> UpdateAllLanguagesAsync(bool continueOnError = true)
{
try
{
var outputDir = _configuration["Translation:OutputDirectory"] ?? "translations";
if (!Directory.Exists(outputDir))
{
_logger.LogError("Translation directory not found: {OutputDir}", outputDir);
return new Dictionary<string, bool>();
}
var translationFiles = Directory.GetFiles(outputDir, "*.json");
if (translationFiles.Length == 0)
{
_logger.LogWarning("No translation files found in {OutputDir}", outputDir);
return new Dictionary<string, bool>();
}
_logger.LogInformation("Found {Count} translation files to update", translationFiles.Length);
var languageCodes = new List<string>();
foreach (var filePath in translationFiles)
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
var languageEntry = SupportedLanguages.Languages
.FirstOrDefault(kvp => kvp.Value.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase));
if (!languageEntry.Equals(default(KeyValuePair<string, LanguageInfo>)))
{
languageCodes.Add(languageEntry.Key);
_logger.LogInformation(" - {FileName} -> {LanguageCode} ({LanguageName})",
fileName, languageEntry.Key, languageEntry.Value.Name);
}
else
{
_logger.LogWarning(" - {FileName} -> Unknown language, skipping", fileName);
}
}
if (languageCodes.Count == 0)
{
_logger.LogError("No valid language files found to update");
return new Dictionary<string, bool>();
}
_logger.LogInformation("Starting update for {Count} languages", languageCodes.Count);
// Fetch source once for all languages (either from BTCPay Server or GitHub)
var sourceTranslations = await GetSourceTranslationsAsync();
_logger.LogInformation("Found {Count} strings in source", sourceTranslations.Count);
return await UpdateMultipleLanguagesWithSourceAsync(languageCodes, sourceTranslations, continueOnError);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during update-all process");
return new Dictionary<string, bool>();
}
}
private async Task<Dictionary<string, bool>> UpdateMultipleLanguagesWithSourceAsync(
IEnumerable<string> languageCodes,
Dictionary<string, string> sourceTranslations,
bool continueOnError = true)
{
var results = new Dictionary<string, bool>();
foreach (var languageCode in languageCodes)
{
try
{
_logger.LogInformation("Starting update for language: {LanguageCode}", languageCode);
var success = await UpdateLanguageWithSourceAsync(languageCode, sourceTranslations);
results[languageCode] = success;
if (!success && !continueOnError)
{
_logger.LogWarning("Update failed for {LanguageCode}, stopping batch process", languageCode);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating language {LanguageCode}", languageCode);
results[languageCode] = false;
if (!continueOnError)
{
break;
}
}
}
var totalLanguages = results.Count;
var successfulLanguages = results.Values.Count(success => success);
_logger.LogInformation("Batch update completed: {SuccessCount}/{TotalCount} languages successful",
successfulLanguages, totalLanguages);
return results;
}
private async Task<bool> UpdateLanguageWithSourceAsync(string languageCode, Dictionary<string, string> sourceTranslations)
{
try
{
var languageInfo = SupportedLanguages.GetLanguageInfo(languageCode);
if (languageInfo == null)
{
_logger.LogError("Unsupported language code: {LanguageCode}", languageCode);
return false;
}
var outputDir = _configuration["Translation:OutputDirectory"] ?? "translations";
var outputPath = Path.Combine(outputDir, $"{languageInfo.Name.ToLower()}.json");
if (!File.Exists(outputPath))
{
_logger.LogError("Translation file not found: {OutputPath}", outputPath);
return false;
}
var existingTranslations = await _fileWriter.LoadExistingBackendTranslationsAsync(outputPath);
_logger.LogInformation("Loaded {Count} existing translations for {Language}", existingTranslations.Count, languageInfo.Name);
var newKeys = sourceTranslations.Keys.Except(existingTranslations.Keys).ToList();
var deletedKeys = existingTranslations.Keys.Except(sourceTranslations.Keys).ToList();
_logger.LogInformation("{Language}: {NewCount} new, {DeletedCount} deleted, {UnchangedCount} unchanged",
languageInfo.Name, newKeys.Count, deletedKeys.Count, existingTranslations.Keys.Intersect(sourceTranslations.Keys).Count());
if (newKeys.Count == 0 && deletedKeys.Count == 0)
{
_logger.LogInformation("{Language} is up to date", languageInfo.Name);
return true;
}
var translationsToProcess = newKeys.ToDictionary(k => k, k => sourceTranslations[k]);
if (translationsToProcess.Count > 0)
{
_logger.LogInformation("Translating {Count} new strings for {Language}...", translationsToProcess.Count, languageInfo.Name);
var batchSize = _configuration.GetValue<int>("Translation:BatchSize", 50);
var requests = translationsToProcess
.Select(t => new TranslationRequest(t.Key, t.Value, languageInfo.Name))
.ToList();
var allResults = new List<TranslationResponse>();
for (int i = 0; i < requests.Count; i += batchSize)
{
var batch = requests.Skip(i).Take(batchSize).ToList();
_logger.LogInformation("Processing batch {CurrentBatch}/{TotalBatches} ({Count} items)",
(i / batchSize) + 1, (int)Math.Ceiling((double)requests.Count / batchSize), batch.Count);
var batchRequest = new BatchTranslationRequest(batch, languageInfo.Name, languageInfo.NativeName);
var batchResponse = await _translationService.TranslateBatchAsync(batchRequest);
allResults.AddRange(batchResponse.Results);
if (i + batchSize < requests.Count)
{
var delay = _configuration.GetValue<int>("Translation:DelayBetweenRequests", 1000);
await Task.Delay(delay);
}
}
var newTranslations = allResults
.Where(r => r.Success)
.ToDictionary(r => r.Key, r => r.TranslatedText);
_logger.LogInformation("Successfully translated {SuccessCount}/{TotalCount} new strings for {Language}",
newTranslations.Count, translationsToProcess.Count, languageInfo.Name);
foreach (var newTranslation in newTranslations)
{
existingTranslations[newTranslation.Key] = newTranslation.Value;
}
}
foreach (var deletedKey in deletedKeys)
{
existingTranslations.Remove(deletedKey);
}
var finalTranslations = new Dictionary<string, string>();
foreach (var sourceKey in sourceTranslations.Keys)
{
if (existingTranslations.ContainsKey(sourceKey))
{
finalTranslations[sourceKey] = existingTranslations[sourceKey];
}
}
await _fileWriter.WriteBackendTranslationFileAsync(
outputPath, languageInfo, finalTranslations);
_logger.LogInformation(
"{Language} updated: {TotalCount} total strings ({NewCount} added, {DeletedCount} removed)",
languageInfo.Name, finalTranslations.Count, newKeys.Count, deletedKeys.Count);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during update process for language {LanguageCode}", languageCode);
return false;
}
}
/// <summary>
/// Inserts newly-added English source keys into existing translation files as English placeholders,
/// without translating and without removing any keys. Existing entries are preserved byte-for-byte.
/// </summary>
/// <param name="languageCodes">Optional filter; null/empty refreshes every discovered file.</param>
public async Task<RefreshResult> RefreshKeysAsync(IEnumerable<string>? languageCodes = null)
{
var addedByFile = new Dictionary<string, int>();
var outputDir = _configuration["Translation:OutputDirectory"] ?? "translations";
if (!Directory.Exists(outputDir))
{
_logger.LogError("Translation directory not found: {OutputDir}", outputDir);
return new RefreshResult(0, 0, 0, addedByFile);
}
var filterCodes = languageCodes is null
? null
: languageCodes
.Where(c => !string.IsNullOrWhiteSpace(c))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (filterCodes is { Count: 0 })
filterCodes = null;
var sourceTranslations = await GetSourceTranslationsAsync();
_logger.LogInformation("Found {Count} strings in source", sourceTranslations.Count);
var translationFiles = Directory.GetFiles(outputDir, "*.json")
.Where(p => !p.EndsWith(".report.json", StringComparison.OrdinalIgnoreCase))
.OrderBy(p => p)
.ToList();
var processed = 0;
var skipped = 0;
var totalAdded = 0;
foreach (var filePath in translationFiles)
{
var fileName = Path.GetFileNameWithoutExtension(filePath);
var match = SupportedLanguages.GetLanguageInfoByName(fileName);
if (match is null)
{
_logger.LogWarning(" - {FileName} -> Unknown language, skipping", fileName);
skipped++;
continue;
}
var (code, _) = match.Value;
if (filterCodes != null && !filterCodes.Contains(code))
continue;
try
{
var added = await _fileWriter.InsertMissingKeysAsync(filePath, sourceTranslations);
addedByFile[Path.GetFileName(filePath)] = added;
totalAdded += added;
processed++;
_logger.LogInformation(" {FileName}: +{Added}", Path.GetFileName(filePath), added);
}
catch (Exception ex)
{
_logger.LogError(ex, " {FileName}: failed to insert missing keys", Path.GetFileName(filePath));
skipped++;
}
}
_logger.LogInformation(
"refresh-keys completed: {TotalAdded} key(s) added across {Processed} file(s) ({Skipped} skipped)",
totalAdded, processed, skipped);
return new RefreshResult(processed, skipped, totalAdded, addedByFile);
}
}
public sealed record RefreshResult(
int FilesProcessed,
int FilesSkipped,
int TotalKeysAdded,
IReadOnlyDictionary<string, int> AddedByFile);

View File

@ -0,0 +1,365 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace BTCPayTranslator.Services;
internal static class TranslationValidationRules
{
private static readonly Regex PlaceholderRegex =
new(@"\{[A-Za-z0-9_]+\}", RegexOptions.Compiled);
private static readonly Regex HtmlTagRegex =
new(@"<[^>]+>", RegexOptions.Compiled);
private static readonly Regex StructuralHtmlTagRegex =
new(@"<\s*/?\s*(strong|em|b|i|u|code|pre|kbd|small|sub|sup|mark|br|p|div|span|a|ul|ol|li|h[1-6]|table|thead|tbody|tr|td|th|abbr|del|ins|q|cite|var|samp)\b[^>]*>",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex MaintainerFieldRegex =
new(@"^[^|]+\|https://\S+$", RegexOptions.Compiled);
private static readonly Regex WhitespaceRegex =
new(@"\s+", RegexOptions.Compiled);
private static readonly Regex TokenRegex =
new(@"[A-Za-z0-9+./_-]+", RegexOptions.Compiled);
private static readonly Regex ShortEnglishLabelRegex =
new(@"^[A-Za-z][A-Za-z0-9'() ./-]*$", RegexOptions.Compiled);
private static readonly Regex[] SuspiciousMetaPatterns =
{
// English
new(@"\bplease provide (the )?english text\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\bwaiting for the english text\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\bi\s*(?:am|'m) ready to translate\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\bready to translate english(?:\s+to\s+[a-z\s\-()]+)?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\btranslate english text to\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\bplease provide the text (?:you(?:'d)? like me to translate|you want me to translate|to translate)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\bi understand(?:\s+the\s+instructions)?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\bi don't see any text\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\byou haven't provided any text\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\bprofessional translator for btcpay server\b", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"\bas an ai\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)
};
// Localized meta-response patterns: phrases in non-English languages that indicate
// the LLM replied with "waiting for text" / "ready to translate" instead of translating.
private static readonly Regex[] LocalizedMetaPatterns =
{
// German
new(@"geben Sie den zu \u00fcbersetzenden", RegexOptions.IgnoreCase | RegexOptions.Compiled), // "provide the text to translate"
new(@"Bereit f\u00fcr die \u00dcbersetzung", RegexOptions.IgnoreCase | RegexOptions.Compiled), // "Ready for translation"
new(@"ich kann .*\u00fcbersetzen", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"(?:\u00fcbersetze|\u00fcbersetzen) .*englisch", RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Dutch
new(@"ik ben (?:een )?(?:professionele )?vertaler", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"(?:geef|geef me) .*engelse tekst", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"klaar om te vertalen", RegexOptions.IgnoreCase | RegexOptions.Compiled),
// French
new(@"(?:attends|fournir|fourni(?:r|ssez)) le texte \u00e0 traduire", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"ne (?:peux|vois) pas traduire sans texte", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"je peux traduire", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"(?:traduis|traduire) .*anglais", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"je suis (?:un )?traducteur", RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Italian
new(@"fornisci il testo da tradurre", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"(?:pronto|attendo|serve).*tradurre", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"non vedo il testo da tradurre", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"posso tradurre dall'?inglese", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"traduci dall'?inglese in italiano", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"sono un traduttore", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"posso aiutare a tradurre", RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Portuguese
new(@"forne\u00e7a o texto em ingl\u00eas", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"gostaria que eu traduzisse", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"posso traduzir do ingl\u00eas", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"sou (?:um )?tradutor", RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Spanish
new(@"proporcione el texto en ingl\u00e9s", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"necesita ser traducido", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"puedo traducir del ingl\u00e9s", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"traduce del ingl\u00e9s", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"soy (?:un )?traductor", RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Thai
new(@"\u0e01\u0e23\u0e38\u0e13\u0e32\u0e43\u0e2b\u0e49\u0e02\u0e49\u0e2d\u0e04\u0e27\u0e32\u0e21", RegexOptions.Compiled), // "กรุณาให้ข้อความ"
new(@"\u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e41\u0e1b\u0e25", RegexOptions.Compiled), // "พร้อมแปล"
new(@"\u0e02\u0e49\u0e2d\u0e04\u0e27\u0e32\u0e21\u0e17\u0e35\u0e48\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e41\u0e1b\u0e25", RegexOptions.Compiled), // "ข้อความที่ต้องการแปล"
// Japanese
new(@"\u7ffb\u8a33\u3059\u308b.*\u30c6\u30ad\u30b9\u30c8\u3092\u63d0\u4f9b", RegexOptions.Compiled), // "翻訳する...テキストを提供"
// Korean
new(@"\ubc88\uc5ed\ud560 \uc6d0\ubb38\uc774 \uc81c\uacf5", RegexOptions.Compiled), // "번역할 원문이 제공"
new(@"\uc601\uc5b4 \ud14d\uc2a4\ud2b8\ub97c \uc81c\uacf5", RegexOptions.Compiled), // "영어 텍스트를 제공"
// Indonesian
new(@"berikan teks yang perlu diterjemahkan", RegexOptions.IgnoreCase | RegexOptions.Compiled),
new(@"menunggu teks bahasa Inggris", RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Serbian
new(@"dajte mi tekst za prevod", RegexOptions.IgnoreCase | RegexOptions.Compiled),
// Russian
new(@"\u0442\u0435\u043a\u0441\u0442 \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0432\u043e\u0434\u0430", RegexOptions.Compiled), // "текст для перевода"
};
// Allowlist of short labels that can legitimately appear unchanged in many locales.
private static readonly HashSet<string> ShortKeyAllowlist = new(StringComparer.Ordinal)
{
"Reset",
"No", "Start", "Source", "Done", "Save", "Send", "Image",
"API", "URL", "URI", "JSON", "CSV", "PSBT", "BTC", "LNURL", "Tor",
};
// Focused hotspot keys that have repeatedly been contaminated with English fallback values.
//
// NOTE on legitimate identical-to-English entries: some locales can have a hotspot key
// whose correct translation IS the same as the English source (loan-words, protocol/brand
// names used as-is, short commands adopted verbatim). When that happens the validator
// will surface a false-positive "Common UI label left untranslated" warning for that
// (file, key) pair. The right response is usually to provide a proper translation so
// UIs render consistently across locales (e.g. Serbian 'RESET' -> 'RESETUJ'); if the
// word genuinely has no localized form, consider adding a per-locale allowlist to
// IsShortKeyEnglishFallback rather than removing the key from this set (which would
// weaken detection globally).
private static readonly HashSet<string> ShortKeyHotspotKeys = new(StringComparer.Ordinal)
{
"Change Role",
"Confirm",
"Continue",
"Edit",
"Edit plan",
"here",
"Inputs",
"Invalid role",
"Modify",
"New role",
"Next",
"Redeliver",
"Regenerate",
"Retry",
"Text",
"Translations",
"Update Role",
"Yes",
"RESET",
"Role updated",
"Role created",
"Copy Code",
"More details...",
"More information...",
};
private static readonly HashSet<string> TechnicalAllowTokens = new(StringComparer.OrdinalIgnoreCase)
{
"api",
"apis",
"btc",
"lnurl",
"lnurlp",
"auth",
"node",
"grpc",
"ssl",
"cipher",
"suite",
"suites",
"bolt11",
"bolt12",
"bip21",
"json",
"csv",
"http",
"https",
"url",
"uri",
"oauth",
"webhook",
"webhooks",
"docker",
"github",
"btcpay",
"bitcoin",
"lightning",
"nostr",
"nfc",
"tor",
"psbt"
};
public static bool IsSuspiciousMetaResponse(string text)
{
if (string.IsNullOrWhiteSpace(text))
return false;
return SuspiciousMetaPatterns.Any(pattern => pattern.IsMatch(text))
|| LocalizedMetaPatterns.Any(pattern => pattern.IsMatch(text));
}
/// <summary>
/// Detects short, common UI keys (Confirm, Continue, Yes, etc.) that were
/// left as English instead of being translated. See the note on
/// ShortKeyHotspotKeys for how to handle genuinely identical-to-English
/// loan-word cases.
/// </summary>
public static bool IsShortKeyEnglishFallback(string key, string value)
{
if (!string.Equals(key, value, StringComparison.Ordinal))
return false;
return IsShortKeyFallbackHotspot(key);
}
public static bool IsShortKeyFallbackHotspot(string key)
{
if (string.IsNullOrWhiteSpace(key))
return false;
if (ShortKeyAllowlist.Contains(key))
return false;
if (PlaceholderRegex.IsMatch(key))
return false;
var trimmed = key.Trim();
if (trimmed.Length == 0 || trimmed.Length > 20)
return false;
if (!ShortEnglishLabelRegex.IsMatch(trimmed))
return false;
var words = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length is < 1 or > 2)
return false;
return ShortKeyHotspotKeys.Contains(trimmed);
}
public static bool HasMatchingPlaceholders(string source, string translation)
{
var sourceTokens = ExtractTokenCounts(source);
var translationTokens = ExtractTokenCounts(translation);
if (sourceTokens.Count != translationTokens.Count)
return false;
foreach (var token in sourceTokens)
{
if (!translationTokens.TryGetValue(token.Key, out var count) || count != token.Value)
{
return false;
}
}
return true;
}
/// <summary>
/// Checks that the source and translation use the same multiset of structural HTML tags (case-insensitive).
/// </summary>
public static bool HasMatchingHtmlTags(string source, string translation)
{
var sourceTags = ExtractStructuralTagCounts(source);
var translationTags = ExtractStructuralTagCounts(translation);
if (sourceTags.Count != translationTags.Count)
return false;
foreach (var entry in sourceTags)
{
if (!translationTags.TryGetValue(entry.Key, out var count) || count != entry.Value)
return false;
}
return true;
}
/// <summary>
/// Validates the shape of the _maintainer field that ManifestGenerator expects
/// </summary>
public static bool IsValidMaintainerValue(string? value)
{
// if language don't have maintainer
if (value is null)
return true;
if (string.IsNullOrWhiteSpace(value))
return false;
return MaintainerFieldRegex.IsMatch(value.Trim());
}
public static bool IsLikelySentenceFallback(string source, string translation)
{
if (!string.Equals(source, translation, StringComparison.Ordinal))
return false;
if (string.IsNullOrWhiteSpace(source) || source.Length < 20)
return false;
var sourceForAnalysis = HtmlTagRegex.Replace(source, " ");
sourceForAnalysis = PlaceholderRegex.Replace(sourceForAnalysis, " ");
sourceForAnalysis = WhitespaceRegex.Replace(sourceForAnalysis, " ").Trim();
if (string.IsNullOrWhiteSpace(sourceForAnalysis) || sourceForAnalysis.Length < 20)
return false;
var words = sourceForAnalysis.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length < 4)
return false;
if (!sourceForAnalysis.Any(char.IsLower))
return false;
var tokens = TokenRegex.Matches(sourceForAnalysis).Select(match => match.Value).ToList();
if (tokens.Count == 0)
return false;
foreach (var token in tokens)
{
if (TechnicalAllowTokens.Contains(token))
continue;
if (token.All(ch => char.IsUpper(ch) || char.IsDigit(ch) || ch == '_' || ch == '-'))
continue;
return true;
}
return false;
}
private static Dictionary<string, int> ExtractTokenCounts(string text)
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (Match match in PlaceholderRegex.Matches(text))
{
if (!counts.TryAdd(match.Value, 1))
{
counts[match.Value]++;
}
}
return counts;
}
private static readonly Regex TagNameRegex = new(@"<\s*/?\s*([A-Za-z][A-Za-z0-9]*)", RegexOptions.Compiled);
private static Dictionary<string, int> ExtractStructuralTagCounts(string text)
{
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (Match match in StructuralHtmlTagRegex.Matches(text))
{
var raw = match.Value;
var nameMatch = TagNameRegex.Match(raw);
if (!nameMatch.Success) continue;
var isClose = raw.TrimStart('<').TrimStart().StartsWith('/');
var key = (isClose ? "/" : string.Empty) + nameMatch.Groups[1].Value.ToLowerInvariant();
if (!counts.TryAdd(key, 1))
counts[key]++;
}
return counts;
}
}

View File

@ -2,7 +2,7 @@
"TranslationService": {
"Provider": "OpenRouter",
"OpenRouter": {
"Model": "anthropic/claude-3.5-sonnet",
"Model": "anthropic/claude-3.6-sonnet",
"BaseUrl": "https://openrouter.ai/api/v1",
"SiteName": "BTCPayTranslator",
"AppName": "https://github.com/btcpayserver/btcpayserver"
@ -11,9 +11,10 @@
"Translation": {
"BatchSize": 40,
"MaxRetries": 3,
"DelayBetweenRequests": 1500,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/Services/Translations.Default.cs",
"OutputDirectory": "translations"
"DelayBetweenRequests": 2000,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/Plugins/Translations/Translations.Default.cs",
"OutputDirectory": "../translations",
"BTCPayUrl": ""
},
"Logging": {
"LogLevel": {

View File

@ -14,3 +14,10 @@ TRANSLATION_MAX_RETRIES=3
TRANSLATION_DELAY_BETWEEN_REQUESTS=1000
TRANSLATION_INPUT_FILE=../BTCPayServer/Services/Translations.Default.cs
TRANSLATION_OUTPUT_DIRECTORY=translations
# BTCPay Server URL (optional)
# Set this to fetch translations from a running BTCPay Server in debug/cheat mode
# instead of parsing Translations.Default.cs from GitHub.
# This captures ALL strings, including those registered via Dependency Injection.
# Requires BTCPay Server to be started with cheatmode=true.
# TRANSLATION_BTCPAY_URL=http://localhost:14142

165
manifest.json Normal file
View File

@ -0,0 +1,165 @@
{
"Languages": [
{
"Code": "nl",
"Bcp47": "nl-NL",
"Name": "Dutch",
"Native": "Nederlands",
"File": "translations/dutch.json",
"Sha": "6f4e7baff2c5418f2b502c3a09880f563bc4636a5a643a0efcfc26ef6a2f9624",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "fr",
"Bcp47": "fr-FR",
"Name": "French",
"Native": "Français",
"File": "translations/french.json",
"Sha": "13527dce6e26bdd4bb4c9e1dd50732b572008a73feb8db4fc013594bb4de32fb",
"Maintainer": "teamssUTXO|https://github.com/teamssUTXO",
"Updated": "2026-06-12T09:02:11Z"
},
{
"Code": "de",
"Bcp47": "de-DE",
"Name": "German",
"Native": "Deutsch",
"File": "translations/german.json",
"Sha": "4f76f9237cf4e2b8ee3773a8618293f4d1c1a9845416b5ab648c934f56976979",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "hi",
"Bcp47": "hi",
"Name": "Hindi",
"Native": "हिंदी",
"File": "translations/hindi.json",
"Sha": "83c355febf165d614e8a04634975acbb77d0c453709a287a70602e6f2acdbd4f",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "id",
"Bcp47": "id",
"Name": "Indonesian",
"Native": "Bahasa Indonesia",
"File": "translations/indonesian.json",
"Sha": "b425358397c804bb54c9f3ea040bb8f1f3a90f1ad6ea24f1c22b8badc017c7f7",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "it",
"Bcp47": "it-IT",
"Name": "Italian",
"Native": "Italiano",
"File": "translations/italian.json",
"Sha": "b145aa858b139c2739e3cbb341936c3949638baac01cca2d9f29ba04ca01c00e",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "ja",
"Bcp47": "ja-JP",
"Name": "Japanese",
"Native": "日本語",
"File": "translations/japanese.json",
"Sha": "825437fd1bde43fe3c44ee19510651ce3a4a843aa0d07be782ea153d74ac2ead",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "ko",
"Bcp47": "ko",
"Name": "Korean",
"Native": "한국어",
"File": "translations/korean.json",
"Sha": "d5ab903bb52210e6468ab8a102fe76d6ba605df95e27b8ffc8a2e8f7377c54b1",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "no",
"Bcp47": "no",
"Name": "Norwegian",
"Native": "Norsk",
"File": "translations/norwegian.json",
"Sha": "e67f1afb1bbcd30aab949c950f66efd94d88ba9a4e20ecefd1e924cda760d008",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "pt",
"Bcp47": "pt-BR",
"Name": "Portuguese (Brazil)",
"Native": "Português (Brasil)",
"File": "translations/portuguese (brazil).json",
"Sha": "70ab09c859bf6db217ecdc65f8e6508cc7492bbfefcb55c48af560de6b2b95e5",
"Maintainer": "thgO-O|https://github.com/thgO-O",
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "ro",
"Bcp47": "ro",
"Name": "Romanian",
"Native": "Română",
"File": "translations/romanian.json",
"Sha": "bc609973f8ef6b1348b9bc828a3140c295869103d3141b4f2b01b3ac4645bd08",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "ru",
"Bcp47": "ru-RU",
"Name": "Russian",
"Native": "Русский",
"File": "translations/russian.json",
"Sha": "615986a9b8d43be52791ef2570ee230ce01d63d6105d0d8426674721eb2f82b8",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "sr",
"Bcp47": "sr",
"Name": "Serbian",
"Native": "Српски",
"File": "translations/serbian.json",
"Sha": "9bc7e70880419e0700b91091254c92f558d40c23aa90a217c942f3ab8da07eb8",
"Maintainer": "sanya|https://github.com/Sanja22B",
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "es",
"Bcp47": "es-ES",
"Name": "Spanish",
"Native": "Español",
"File": "translations/spanish.json",
"Sha": "4f85a3d67fe53cbd4771d2e5cf5967b5af21ec0588ff914721b91276c2e8f0a6",
"Maintainer": "daxsosa|https://github.com/daxsosa",
"Updated": "2026-06-10T23:17:53Z"
},
{
"Code": "th",
"Bcp47": "th-TH",
"Name": "Thai",
"Native": "ไทย",
"File": "translations/thai.json",
"Sha": "9b1119d21c5d479a633963927360700547084ba7379330f5a285d2a5305f975f",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
},
{
"Code": "tr",
"Bcp47": "tr",
"Name": "Turkish",
"Native": "Türkçe",
"File": "translations/turkish.json",
"Sha": "432ba6688ddef9a1952163f0be417eda3002fc782461fc963ff07d80c79f113b",
"Maintainer": null,
"Updated": "2026-06-09T15:32:50Z"
}
],
"Redirect": null
}

2540
translations/dutch.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2540
translations/indonesian.json Normal file

File diff suppressed because it is too large Load Diff

2540
translations/italian.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2540
translations/korean.json Normal file

File diff suppressed because it is too large Load Diff

2540
translations/norwegian.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2553
translations/romanian.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2540
translations/thai.json Normal file

File diff suppressed because it is too large Load Diff

2540
translations/turkish.json Normal file

File diff suppressed because it is too large Load Diff