btcpayserver-translator/Translator/Program.cs

632 lines
23 KiB
C#

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.");
}
}