Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 150 additions & 1 deletion Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ static async Task<int> Main(string[] args)
CreateTranslateCommand(serviceProvider),
CreateListLanguagesCommand(),
CreateBatchCommand(serviceProvider),
CreateStatusCommand(serviceProvider)
CreateStatusCommand(serviceProvider),
CreateCheckoutTranslateCommand(serviceProvider),
CreateCheckoutBatchCommand(serviceProvider),
CreateCheckoutStatusCommand(serviceProvider)
};

return await rootCommand.InvokeAsync(args);
Expand Down Expand Up @@ -230,4 +233,150 @@ private static Command CreateStatusCommand(ServiceProvider serviceProvider)

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;
}
}
58 changes: 54 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -104,31 +138,47 @@ 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"
}
}
```

**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
Expand Down
109 changes: 109 additions & 0 deletions Services/TranslationExtractor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,113 @@ private string ConvertToRawUrl(string url)
}
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";
}
}
Loading