Compare commits

...

1 Commits

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

View File

@ -43,7 +43,10 @@ class Program
CreateTranslateCommand(serviceProvider),
CreateListLanguagesCommand(),
CreateBatchCommand(serviceProvider),
CreateStatusCommand(serviceProvider)
CreateStatusCommand(serviceProvider),
CreateCheckoutTranslateCommand(serviceProvider),
CreateCheckoutBatchCommand(serviceProvider),
CreateCheckoutStatusCommand(serviceProvider)
};
return await rootCommand.InvokeAsync(args);
@ -230,4 +233,150 @@ class Program
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;
}
}

View File

@ -5,6 +5,7 @@ A command-line tool to translate BTCPay Server's UI text to multiple languages u
## Features
- Translates BTCPay Server's default English strings to any supported language
- Checkout Translations - Dedicated support for translating checkout page strings
- Uses OpenRouter API with various AI models
- URL Support: Download translations directly from GitHub URLs
- Batch processing with configurable concurrency and rate limiting
@ -47,7 +48,8 @@ OPENROUTER_APP_NAME=https://github.com/btcpayserver/btcpayserver
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
@ -73,6 +75,38 @@ dotnet run -- batch --languages hi es fr de --force
dotnet run -- status
```
### for Checkout page Translations
The tool now supports dedicated checkout translation commands for translating BTCPay Server's checkout page.
#### Translate Checkout to a Single Language
```bash
# Translate checkout to Spanish
dotnet run -- checkout-translate --language es
# Force retranslation of all checkout strings
dotnet run -- checkout-translate --language es --force
```
#### Batch Checkout Translation to Multiple Languages
```bash
# Translate checkout to multiple languages
dotnet run -- checkout-batch --languages hi es fr de
# 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
```
#### Check Checkout Translation Status
```bash
dotnet run -- checkout-status
```
**Checkout translations are stored separately in the `checkoutTranslations/` folder.**
## Supported Languages
The tool supports 100+ languages including:
@ -104,6 +138,13 @@ The tool supports 100+ languages including:
"DelayBetweenRequests": 1000,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/Services/Translations.Default.cs",
"OutputDirectory": "translations"
},
"CheckoutTranslation": {
"BatchSize": 40,
"MaxRetries": 3,
"DelayBetweenRequests": 1500,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/locales/checkout/en.json",
"OutputDirectory": "checkoutTranslations"
}
}
```
@ -111,24 +152,33 @@ 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

View File

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

View File

@ -181,4 +181,151 @@ public class TranslationOrchestrator
return results;
}
public async Task<bool> TranslateCheckoutToLanguageAsync(string languageCode, bool forceRetranslate = false)
{
try
{
var languageInfo = SupportedLanguages.GetLanguageInfo(languageCode);
if (languageInfo == null)
{
_logger.LogError("Unsupported language code: {LanguageCode}", languageCode);
return false;
}
_logger.LogInformation("Starting checkout translation to {Language} ({NativeName})",
languageInfo.Name, languageInfo.NativeName);
// Extract source checkout translations from en.json
var inputFile = _configuration["CheckoutTranslation:InputFile"] ??
"https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/locales/checkout/en.json";
var sourceTranslations = await _extractor.ExtractFromCheckoutJsonAsync(inputFile);
// Determine output paths
var outputDir = _configuration["CheckoutTranslation:OutputDirectory"] ??
"checkoutTranslations";
var outputPath = Path.Combine(outputDir, $"{languageInfo.Code}.json");
// Load existing translations if they exist
var existingTranslations = await _fileWriter.LoadExistingBackendTranslationsAsync(outputPath);
// Determine what needs to be translated
Dictionary<string, string> translationsToProcess;
if (forceRetranslate)
{
translationsToProcess = sourceTranslations;
_logger.LogInformation("Force retranslate mode: processing all {Count} checkout translations",
sourceTranslations.Count);
}
else
{
translationsToProcess = _extractor.GetTranslationsToUpdate(sourceTranslations, existingTranslations);
if (translationsToProcess.Count == 0)
{
_logger.LogInformation("No new checkout translations needed for {Language}", languageInfo.Name);
return true;
}
}
// Prepare translation requests for ALL translations
var batchSize = _configuration.GetValue<int>("CheckoutTranslation:BatchSize", 40);
var requests = translationsToProcess
.Select(t => new TranslationRequest(t.Key, t.Value, languageInfo.Name))
.ToList();
// Process translations in batches
var allResults = new List<TranslationResponse>();
for (int i = 0; i < requests.Count; i += batchSize)
{
var batch = requests.Skip(i).Take(batchSize).ToList();
_logger.LogInformation("Processing checkout batch {CurrentBatch}/{TotalBatches} ({Count} items)",
(i / batchSize) + 1, (int)Math.Ceiling((double)requests.Count / batchSize), batch.Count);
var batchRequest = new BatchTranslationRequest(batch, languageInfo.Name, languageInfo.NativeName);
var batchResponse = await _translationService.TranslateBatchAsync(batchRequest);
allResults.AddRange(batchResponse.Results);
// Add delay between batches to be respectful to the API
if (i + batchSize < requests.Count)
{
var delay = _configuration.GetValue<int>("CheckoutTranslation:DelayBetweenRequests", 1000);
await Task.Delay(delay);
}
}
// Process results
var newTranslations = allResults
.Where(r => r.Success)
.ToDictionary(r => r.Key, r => r.TranslatedText);
var finalTranslations = _extractor.MergeTranslations(existingTranslations, newTranslations);
// Write checkout translation file (with metadata)
await _fileWriter.WriteCheckoutTranslationFileAsync(
outputPath, languageInfo, finalTranslations);
// Write summary report
var summaryResponse = new BatchTranslationResponse(
allResults,
allResults.Count(r => r.Success),
allResults.Count(r => !r.Success),
TimeSpan.Zero);
await _fileWriter.WriteSummaryReportAsync(
outputPath, languageInfo.Name, summaryResponse, finalTranslations);
var successRate = (double)newTranslations.Count / translationsToProcess.Count * 100;
_logger.LogInformation(
"Checkout translation completed for {Language}: {SuccessCount}/{TotalCount} successful ({SuccessRate:F1}%)",
languageInfo.Name, newTranslations.Count, translationsToProcess.Count, successRate);
return successRate > 80; // Consider successful if >80% success rate
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during checkout translation process for language {LanguageCode}", languageCode);
return false;
}
}
public async Task<Dictionary<string, bool>> TranslateCheckoutToMultipleLanguagesAsync(
IEnumerable<string> languageCodes,
bool forceRetranslate = false,
bool continueOnError = true)
{
var results = new Dictionary<string, bool>();
foreach (var languageCode in languageCodes)
{
try
{
_logger.LogInformation("Starting checkout translation for language: {LanguageCode}", languageCode);
var success = await TranslateCheckoutToLanguageAsync(languageCode, forceRetranslate);
results[languageCode] = success;
if (!success && !continueOnError)
{
_logger.LogWarning("Checkout translation failed for {LanguageCode}, stopping batch process", languageCode);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error translating checkout for language {LanguageCode}", languageCode);
results[languageCode] = false;
if (!continueOnError)
{
break;
}
}
}
var totalLanguages = results.Count;
var successfulLanguages = results.Values.Count(success => success);
_logger.LogInformation("Batch checkout translation completed: {SuccessCount}/{TotalCount} languages successful",
successfulLanguages, totalLanguages);
return results;
}
}

View File

@ -15,6 +15,13 @@
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/Services/Translations.Default.cs",
"OutputDirectory": "translations"
},
"CheckoutTranslation": {
"BatchSize": 40,
"MaxRetries": 3,
"DelayBetweenRequests": 1500,
"InputFile": "https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/locales/checkout/en.json",
"OutputDirectory": "checkoutTranslations"
},
"Logging": {
"LogLevel": {
"Default": "Information",

View File

@ -0,0 +1,45 @@
{
"NOTICE_WARN": "THIS CODE HAS BEEN AUTOMATICALLY GENERATED FROM TRANSIFEX, IF YOU WISH TO HELP TRANSLATION COME ON THE SLACK https://chat.btcpayserver.org/ TO REQUEST PERMISSION TO https://www.transifex.com/btcpayserver/btcpayserver/",
"code": "hi",
"currentLanguage": "हिंदी",
"address": "पता",
"amount_due": "देय राशि",
"amount_paid": "भुगतान की गई राशि",
"any_amount": "कोई भी राशि",
"contact_us": "हमसे संपर्क करें",
"conversion_body": "यह सेवा तृतीय पक्ष द्वारा प्रदान की जाती है। कृपया ध्यान रखें कि प्रदाता आपके फंड को कैसे आगे भेजेंगे, इस पर हमारा कोई नियंत्रण नहीं है। इनवॉइस को केवल तभी भुगतान किया हुआ चिह्नित किया जाएगा जब {{cryptoCode}} ब्लॉक",
"copy": "कॉपी",
"copy_confirm": "कॉपी किया गया",
"exchange_rate": "विनिमय दर",
"expiry_info": "यह इनवॉइस समाप्त हो जाएगा",
"fee_rate": "{{feeRate}} सैट/बाइट",
"invoice_expired": "इनवॉइस समाप्त हो गया है",
"invoice_expired_body": "इनवॉइस केवल {{minutes}} मिनट के लिए मान्य है।\n\nभुगतान फिर से जमा करने के लिए {{storeName}} पर वापस जाएं।",
"invoice_id": "इनवॉइस आईडी",
"invoice_paid": "इनवॉइस भुगतान हो गया",
"invoice_paidpartial_body": "इनवॉइस केवल {{minutes}} मिनट के लिए मान्य है।\n\nयह इनवॉइस आंशिक भुगतान के साथ समाप्त हो गया है। कृपया हमसे संपर्क करें, ताकि हम आपके ऑर्डर को पूरा कर सकें।",
"lightning": "लाइटनिंग",
"network_cost": "नेटवर्क लागत",
"order_id": "ऑर्डर आईडी",
"partial_payment_info": "इनवॉइस का पूरा भुगतान नहीं किया गया है।",
"pay_by_lnurl": "एलएनयूआरएल-विथड्रॉ द्वारा भुगतान करें",
"pay_by_nfc": "एनएफसी से भुगतान करें",
"pay_in_wallet": "वॉलेट में भुगतान करें",
"pay_with": "भुगतान करें",
"payment_link": "पेमेंट लिंक",
"payment_received": "भुगतान प्राप्त हुआ",
"payment_received_body": "आपका भुगतान प्राप्त हो गया है और अब प्रोसेस हो रहा है।",
"payment_received_confirmations": "आपका भुगतान {{cryptoCode}} ब्लॉकचेन पर {{requiredConfirmations}} कन्फर्मेशन प्राप्त करने के बाद, आपका इनवॉइस सेटल हो जाएगा।",
"powered_by": "द्वारा संचालित",
"qr_text": "क्यूआर कोड स्कैन करें, या पता कॉपी करने के लिए टैप करें।",
"recommended_fee": "अनुशंसित शुल्क",
"return_to_store": "{{storeName}} पर वापस जाएं",
"scanning_nfc": "एनएफसी स्कैन हो रहा है...",
"still_due": "कृपया नीचे दिए गए पते पर {{amount}} भेजें।",
"submitting_nfc": "एनएफसी सबमिट हो रहा है...",
"total_fiat": "कुल फिएट",
"total_price": "कुल कीमत",
"tx_count": "{{count}} लेनदेन",
"view_details": "विवरण देखें",
"view_receipt": "रसीद देखें"
}