Compare commits
1 Commits
main
...
checkoutTr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf6461c0b |
43
.github/workflows/manifest.yml
vendored
43
.github/workflows/manifest.yml
vendored
@ -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
|
||||
44
.github/workflows/tests.yml
vendored
44
.github/workflows/tests.yml
vendored
@ -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
1
.gitignore
vendored
@ -16,7 +16,6 @@ obj/
|
||||
.vscode/
|
||||
*.user
|
||||
*.suo
|
||||
*.idea
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
|
||||
@ -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>
|
||||
@ -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
123
Models/LanguageInfo.cs
Normal 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
382
Program.cs
Normal 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
134
README.md
@ -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.
|
||||
|
||||
|
||||
@ -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
188
Services/FileWriter.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
331
Services/TranslationOrchestrator.cs
Normal file
331
Services/TranslationOrchestrator.cs
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
@ -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.");
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 = " | ||||