Compare commits

..

1 Commits

Author SHA1 Message Date
Abhijay007
2bf6461c0b feat : add support to translate checkout page
Signed-off-by: Abhijay007 <Abhijay007j@gmail.com>
2025-10-25 08:41:08 +00:00
55 changed files with 3608 additions and 33360 deletions

View File

@ -1,43 +0,0 @@
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

View File

@ -1,44 +0,0 @@
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,7 +16,6 @@ obj/
.vscode/
*.user
*.suo
*.idea
# OS generated files
.DS_Store

View File

@ -2,21 +2,22 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<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="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="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

@ -1,28 +0,0 @@
<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>

123
Models/LanguageInfo.cs Normal file
View File

@ -0,0 +1,123 @@
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;
}
}

382
Program.cs Normal file
View File

@ -0,0 +1,382 @@
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),
CreateCheckoutTranslateCommand(serviceProvider),
CreateCheckoutBatchCommand(serviceProvider),
CreateCheckoutStatusCommand(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;
}
private static Command CreateCheckoutTranslateCommand(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("checkout-translate", "Translate BTCPay Server checkout 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 checkout translation for language: {Language}", language);
var success = await orchestrator.TranslateCheckoutToLanguageAsync(language, force);
if (success)
{
logger.LogInformation("Checkout translation completed successfully!");
Environment.Exit(0);
}
else
{
logger.LogError("Checkout translation failed!");
Environment.Exit(1);
}
}, languageOption, forceOption);
return command;
}
private static Command CreateCheckoutBatchCommand(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("checkout-batch", "Translate BTCPay Server checkout 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 checkout translation for languages: {Languages}",
string.Join(", ", languages));
var results = await orchestrator.TranslateCheckoutToMultipleLanguagesAsync(languages, force, continueOnError);
var successCount = results.Values.Count(success => success);
var totalCount = results.Count;
logger.LogInformation("Batch checkout 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 CreateCheckoutStatusCommand(ServiceProvider serviceProvider)
{
var command = new Command("checkout-status", "Show checkout 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["CheckoutTranslation:OutputDirectory"] ??
"checkoutTranslations";
Console.WriteLine("Checkout 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.Code}.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;
}
}

134
README.md
View File

@ -5,6 +5,7 @@ A command-line tool to translate BTCPay Server's UI text to multiple languages u
## Features
- Translates BTCPay Server's default English strings to any supported language
- Checkout Translations - Dedicated support for translating checkout page strings
- Uses OpenRouter API with various AI models
- URL Support: Download translations directly from GitHub URLs
- Batch processing with configurable concurrency and rate limiting
@ -42,19 +43,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
```
### Translate to a Single Language
### Translate to a Single Language for BTCPayServer App
```bash
# Translate to Hindi
dotnet run -- translate --language hi
@ -80,95 +75,37 @@ dotnet run -- batch --languages hi es fr de --force
dotnet run -- status
```
### Update Existing Translation with New Strings
### for Checkout page Translations
#### Update Single Language
The tool now supports dedicated checkout translation commands for translating BTCPay Server's checkout page.
#### Translate Checkout to a Single Language
```bash
# Update Hindi translation with latest strings from GitHub
dotnet run -- update --language hi
# Translate checkout to Spanish
dotnet run -- checkout-translate --language es
# Force retranslation of all checkout strings
dotnet run -- checkout-translate --language es --force
```
#### Update Multiple Languages
#### Batch Checkout Translation to 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
# Translate checkout to multiple languages
dotnet run -- checkout-batch --languages hi es fr de
# Continue on error
dotnet run -- update-all --continue-on-error
dotnet run -- checkout-batch --languages hi es fr de --continue-on-error
# Force retranslation
dotnet run -- checkout-batch --languages hi es fr de --force
```
**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.
#### Check Checkout Translation Status
```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
dotnet run -- checkout-status
```
**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.
**Checkout translations are stored separately in the `checkoutTranslations/` folder.**
## Supported Languages
@ -190,7 +127,6 @@ 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)
@ -201,8 +137,14 @@ 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",
"BTCPayUrl": ""
"OutputDirectory": "translations"
},
"CheckoutTranslation": {
"BatchSize": 40,
"MaxRetries": 3,
"DelayBetweenRequests": 1500,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/locales/checkout/en.json",
"OutputDirectory": "checkoutTranslations"
}
}
```
@ -210,26 +152,36 @@ The tool supports 100+ languages including:
**Input File Configuration:**
- **URL Support**: You can use either a local file path or a URL to the BTCPayServer translations file
- **GitHub URLs**: The tool automatically converts GitHub blob URLs to raw URLs for direct content access
- **Examples**:
- **Backend Translations**: Default translations from the server backend
- URL: `https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/Services/Translations.Default.cs`
- Local: `../BTCPayServer/Services/Translations.Default.cs`
- **Checkout Translations**: Translations specific to the checkout page
- URL: `https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/locales/checkout/en.json`
- Local: `../BTCPayServer/wwwroot/locales/checkout/en.json`
## Output
Translated files are saved to the configured output directory with the following structure:
```
translations/
translations/ # Backend translations
├── hindi.json
├── spanish.json
├── french.json
└── ...
checkoutTranslations/ # Checkout translations
├── hi.json
├── es.json
├── fr.json
└── ...
```
Each translation file includes:
- All translated strings
- Metadata about the language
- Metadata about the language (for checkout translations)
- Progress reports and error logs
## 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

@ -12,80 +12,103 @@ using Microsoft.Extensions.Logging;
namespace BTCPayTranslator.Services;
public class BaseTranslationService : ITranslationService, IDisposable
public class BaseTranslationService : ITranslationService
{
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, TimeProvider? timeProvider = null)
public BaseTranslationService(HttpClient httpClient, IConfiguration configuration, ILogger<BaseTranslationService> logger)
{
_httpClient = httpClient;
_logger = logger;
// 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";
// 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";
// Optimized for speed but still safe
_semaphore = new SemaphoreSlim(2); // 2 concurrent requests max to avoid rate limits
_timeProvider = timeProvider ?? TimeProvider.System;
_semaphore = new SemaphoreSlim(5); // 5 concurrent requests max
_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)
{
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;
var maxRetries = 2; // Reduced retries for speed
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
var strictMode = lastFailureWasValidation;
lastFailureWasValidation = false;
var maxTokens = ComputeMaxTokens(request.SourceText);
// Optimized prompt for faster processing
var requestBody = new
{
model = _model,
messages = new[]
{
new {
role = "system",
content = BuildSystemPrompt(request.TargetLanguage, strictMode)
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 = "user",
new {
role = "user",
content = request.SourceText
}
},
max_tokens = maxTokens,
temperature = 0.0
max_tokens = 150, // Reduced for faster response
temperature = 0.0, // More deterministic
top_p = 0.9
};
var json = JsonSerializer.Serialize(requestBody);
@ -108,10 +131,10 @@ public class BaseTranslationService : ITranslationService, IDisposable
{
if (attempt == maxRetries)
{
return new TranslationResponse(request.Key, string.Empty, false,
return new TranslationResponse(request.Key, request.SourceText, false,
$"API error: {response.StatusCode}");
}
await Task.Delay(TimeSpan.FromSeconds(1), _timeProvider); // Quick retry delay
await Task.Delay(1000); // Quick retry delay
continue;
}
@ -120,17 +143,17 @@ public class BaseTranslationService : ITranslationService, IDisposable
{
if (attempt == maxRetries)
{
return new TranslationResponse(request.Key, string.Empty, false,
return new TranslationResponse(request.Key, request.SourceText, false,
"HTML error response");
}
await Task.Delay(TimeSpan.FromSeconds(1), _timeProvider);
await Task.Delay(1000);
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))
@ -138,32 +161,13 @@ public class BaseTranslationService : ITranslationService, IDisposable
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, string.Empty, false,
return new TranslationResponse(request.Key, request.SourceText, false,
"No translation returned");
}
}
@ -171,22 +175,21 @@ public class BaseTranslationService : ITranslationService, IDisposable
{
if (attempt == maxRetries)
{
return new TranslationResponse(request.Key, string.Empty, false, ex.Message);
return new TranslationResponse(request.Key, request.SourceText, false, ex.Message);
}
await Task.Delay(TimeSpan.FromSeconds(0,500), _timeProvider); // Quick retry
await Task.Delay(500); // Quick retry
}
}
return new TranslationResponse(request.Key, string.Empty, false, "Translation failed");
return new TranslationResponse(request.Key, request.SourceText, 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 2 concurrent requests",
_logger.LogInformation("Starting FAST batch translation of {Count} items to {Language} with 5 concurrent requests",
request.Items.Count, request.TargetLanguage);
// Process in parallel chunks for speed
@ -208,7 +211,7 @@ public class BaseTranslationService : ITranslationService, IDisposable
);
var result = await TranslateAsync(translationRequest);
// Log progress every 10 items
var currentCount = Interlocked.Increment(ref completedCount);
if (currentCount % 10 == 0)
@ -222,7 +225,7 @@ public class BaseTranslationService : ITranslationService, IDisposable
{
_semaphore.Release();
// Small delay to avoid overwhelming the API
await Task.Delay(TimeSpan.FromSeconds(0,300), _timeProvider); // Increased delay to avoid rate limits
await Task.Delay(100); // Very short delay for speed
}
});
@ -232,7 +235,7 @@ public class BaseTranslationService : ITranslationService, IDisposable
// Brief pause between chunks
if (chunks.Count() > 1)
{
await Task.Delay(TimeSpan.FromSeconds(0,500), _timeProvider);; // Half second between chunks
await Task.Delay(500); // Half second between chunks
}
}
@ -240,14 +243,14 @@ public class BaseTranslationService : ITranslationService, IDisposable
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);
}
@ -269,84 +272,6 @@ public class BaseTranslationService : ITranslationService, IDisposable
}
}
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();

188
Services/FileWriter.cs Normal file
View File

@ -0,0 +1,188 @@
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

@ -2,10 +2,12 @@ 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;
@ -26,45 +28,6 @@ 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
@ -106,13 +69,14 @@ 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;
translations[key] = key; // Use key as default value
}
}
@ -272,4 +236,113 @@ public class TranslationExtractor
}
return url;
}
public async Task<Dictionary<string, string>> ExtractFromCheckoutJsonAsync(string filePathOrUrl)
{
try
{
string content;
string sourceDescription;
if (IsUrl(filePathOrUrl))
{
content = await DownloadCheckoutFileContentAsync(filePathOrUrl);
sourceDescription = $"URL: {filePathOrUrl}";
}
else
{
if (!File.Exists(filePathOrUrl))
{
throw new FileNotFoundException($"Checkout translation file not found: {filePathOrUrl}");
}
content = await File.ReadAllTextAsync(filePathOrUrl);
sourceDescription = $"File: {filePathOrUrl}";
}
// Parse the JSON content
var translations = new Dictionary<string, string>();
var jsonObject = JObject.Parse(content);
foreach (var property in jsonObject.Properties())
{
// Skip metadata fields
if (property.Name is "NOTICE_WARN" or "code" or "currentLanguage")
continue;
var key = property.Name;
var value = property.Value?.ToString() ?? "";
// Include all translation keys
if (!string.IsNullOrEmpty(value))
{
translations[key] = value;
}
}
_logger.LogInformation("Extracted {Count} checkout translations from {Source}", translations.Count, sourceDescription);
return translations;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error extracting checkout translations from {Source}", filePathOrUrl);
throw;
}
}
private async Task<string> DownloadCheckoutFileContentAsync(string url)
{
try
{
// Check cache first
var cacheKey = GetCheckoutCacheKey(url);
var cachePath = Path.Combine(_cacheDirectory, cacheKey);
if (File.Exists(cachePath))
{
var cacheAge = DateTime.Now - File.GetLastWriteTime(cachePath);
// Use cache if it's less than 1 hour old
if (cacheAge.TotalHours < 1)
{
_logger.LogInformation("Using cached checkout file for {Url}", url);
return await File.ReadAllTextAsync(cachePath);
}
}
_logger.LogInformation("Downloading checkout file from {Url}", url);
// Convert GitHub blob URL to raw URL if needed
var downloadUrl = ConvertToRawUrl(url);
var response = await _httpClient.GetAsync(downloadUrl);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
// Cache the content
await File.WriteAllTextAsync(cachePath, content);
_logger.LogInformation("Cached downloaded checkout content to {CachePath}", cachePath);
return content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading checkout file from {Url}", url);
throw;
}
}
private string GetCheckoutCacheKey(string url)
{
// Create a safe filename from the URL
var uri = new Uri(url);
var filename = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrEmpty(filename))
{
filename = "checkout_en.json";
}
// Add a hash of the URL to make it unique
var urlHash = url.GetHashCode().ToString("X");
return $"{Path.GetFileNameWithoutExtension(filename)}_{urlHash}.json";
}
}

View File

@ -0,0 +1,331 @@
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;
}
public async Task<bool> TranslateCheckoutToLanguageAsync(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 checkout translation to {Language} ({NativeName})",
languageInfo.Name, languageInfo.NativeName);
// Extract source checkout translations from en.json
var inputFile = _configuration["CheckoutTranslation:InputFile"] ??
"https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/locales/checkout/en.json";
var sourceTranslations = await _extractor.ExtractFromCheckoutJsonAsync(inputFile);
// Determine output paths
var outputDir = _configuration["CheckoutTranslation:OutputDirectory"] ??
"checkoutTranslations";
var outputPath = Path.Combine(outputDir, $"{languageInfo.Code}.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} checkout translations",
sourceTranslations.Count);
}
else
{
translationsToProcess = _extractor.GetTranslationsToUpdate(sourceTranslations, existingTranslations);
if (translationsToProcess.Count == 0)
{
_logger.LogInformation("No new checkout translations needed for {Language}", languageInfo.Name);
return true;
}
}
// Prepare translation requests for ALL translations
var batchSize = _configuration.GetValue<int>("CheckoutTranslation:BatchSize", 40);
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 checkout 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>("CheckoutTranslation: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 checkout translation file (with metadata)
await _fileWriter.WriteCheckoutTranslationFileAsync(
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(
"Checkout 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 checkout translation process for language {LanguageCode}", languageCode);
return false;
}
}
public async Task<Dictionary<string, bool>> TranslateCheckoutToMultipleLanguagesAsync(
IEnumerable<string> languageCodes,
bool forceRetranslate = false,
bool continueOnError = true)
{
var results = new Dictionary<string, bool>();
foreach (var languageCode in languageCodes)
{
try
{
_logger.LogInformation("Starting checkout translation for language: {LanguageCode}", languageCode);
var success = await TranslateCheckoutToLanguageAsync(languageCode, forceRetranslate);
results[languageCode] = success;
if (!success && !continueOnError)
{
_logger.LogWarning("Checkout translation failed for {LanguageCode}, stopping batch process", languageCode);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error translating checkout for language {LanguageCode}", languageCode);
results[languageCode] = false;
if (!continueOnError)
{
break;
}
}
}
var totalLanguages = results.Count;
var successfulLanguages = results.Values.Count(success => success);
_logger.LogInformation("Batch checkout translation completed: {SuccessCount}/{TotalCount} languages successful",
successfulLanguages, totalLanguages);
return results;
}
}

View File

@ -1,30 +0,0 @@
<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

@ -1,213 +0,0 @@
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

@ -1,99 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,46 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,135 +0,0 @@
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

@ -1,237 +0,0 @@
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

@ -1,129 +0,0 @@
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

@ -1,378 +0,0 @@
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

@ -1,315 +0,0 @@
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

@ -1,177 +0,0 @@
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

@ -1,488 +0,0 @@
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

@ -1,134 +0,0 @@
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

@ -1,20 +0,0 @@
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
);

View File

@ -1,631 +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),
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

@ -1,335 +0,0 @@
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

@ -1,170 +0,0 @@
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

@ -1,171 +0,0 @@
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

@ -1,647 +0,0 @@
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

@ -1,365 +0,0 @@
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.6-sonnet",
"Model": "anthropic/claude-3.5-sonnet",
"BaseUrl": "https://openrouter.ai/api/v1",
"SiteName": "BTCPayTranslator",
"AppName": "https://github.com/btcpayserver/btcpayserver"
@ -11,10 +11,16 @@
"Translation": {
"BatchSize": 40,
"MaxRetries": 3,
"DelayBetweenRequests": 2000,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/Plugins/Translations/Translations.Default.cs",
"OutputDirectory": "../translations",
"BTCPayUrl": ""
"DelayBetweenRequests": 1500,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/Services/Translations.Default.cs",
"OutputDirectory": "translations"
},
"CheckoutTranslation": {
"BatchSize": 40,
"MaxRetries": 3,
"DelayBetweenRequests": 1500,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/locales/checkout/en.json",
"OutputDirectory": "checkoutTranslations"
},
"Logging": {
"LogLevel": {

View File

@ -0,0 +1,45 @@
{
"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": "hi",
"currentLanguage": "हिंदी",
"address": "पता",
"amount_due": "देय राशि",
"amount_paid": "भुगतान की गई राशि",
"any_amount": "कोई भी राशि",
"contact_us": "हमसे संपर्क करें",
"conversion_body": "यह सेवा तृतीय पक्ष द्वारा प्रदान की जाती है। कृपया ध्यान रखें कि प्रदाता आपके फंड को कैसे आगे भेजेंगे, इस पर हमारा कोई नियंत्रण नहीं है। इनवॉइस को केवल तभी भुगतान किया हुआ चिह्नित किया जाएगा जब {{cryptoCode}} ब्लॉक",
"copy": "कॉपी",
"copy_confirm": "कॉपी किया गया",
"exchange_rate": "विनिमय दर",
"expiry_info": "यह इनवॉइस समाप्त हो जाएगा",
"fee_rate": "{{feeRate}} सैट/बाइट",
"invoice_expired": "इनवॉइस समाप्त हो गया है",
"invoice_expired_body": "इनवॉइस केवल {{minutes}} मिनट के लिए मान्य है।\n\nभुगतान फिर से जमा करने के लिए {{storeName}} पर वापस जाएं।",
"invoice_id": "इनवॉइस आईडी",
"invoice_paid": "इनवॉइस भुगतान हो गया",
"invoice_paidpartial_body": "इनवॉइस केवल {{minutes}} मिनट के लिए मान्य है।\n\nयह इनवॉइस आंशिक भुगतान के साथ समाप्त हो गया है। कृपया हमसे संपर्क करें, ताकि हम आपके ऑर्डर को पूरा कर सकें।",
"lightning": "लाइटनिंग",
"network_cost": "नेटवर्क लागत",
"order_id": "ऑर्डर आईडी",
"partial_payment_info": "इनवॉइस का पूरा भुगतान नहीं किया गया है।",
"pay_by_lnurl": "एलएनयूआरएल-विथड्रॉ द्वारा भुगतान करें",
"pay_by_nfc": "एनएफसी से भुगतान करें",
"pay_in_wallet": "वॉलेट में भुगतान करें",
"pay_with": "भुगतान करें",
"payment_link": "पेमेंट लिंक",
"payment_received": "भुगतान प्राप्त हुआ",
"payment_received_body": "आपका भुगतान प्राप्त हो गया है और अब प्रोसेस हो रहा है।",
"payment_received_confirmations": "आपका भुगतान {{cryptoCode}} ब्लॉकचेन पर {{requiredConfirmations}} कन्फर्मेशन प्राप्त करने के बाद, आपका इनवॉइस सेटल हो जाएगा।",
"powered_by": "द्वारा संचालित",
"qr_text": "क्यूआर कोड स्कैन करें, या पता कॉपी करने के लिए टैप करें।",
"recommended_fee": "अनुशंसित शुल्क",
"return_to_store": "{{storeName}} पर वापस जाएं",
"scanning_nfc": "एनएफसी स्कैन हो रहा है...",
"still_due": "कृपया नीचे दिए गए पते पर {{amount}} भेजें।",
"submitting_nfc": "एनएफसी सबमिट हो रहा है...",
"total_fiat": "कुल फिएट",
"total_price": "कुल कीमत",
"tx_count": "{{count}} लेनदेन",
"view_details": "विवरण देखें",
"view_receipt": "रसीद देखें"
}

View File

@ -14,10 +14,3 @@ 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

View File

@ -1,165 +0,0 @@
{
"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
}

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

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

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

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