Files
git.stella-ops.org/src/__Libraries/StellaOps.Localization/TranslationRegistry.cs
2026-03-11 10:07:30 +02:00

275 lines
9.3 KiB
C#

using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Localization;
/// <summary>
/// Singleton that holds all loaded translation bundles and resolves keys.
/// Priority cascade: DB overrides (100) > service-local JSON (10) > common JSON (0).
/// Fallback chain: requested locale -> language-only -> default locale -> key itself.
/// </summary>
public sealed class TranslationRegistry
{
// locale -> (key -> value), already merged by priority
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> _store = new(StringComparer.OrdinalIgnoreCase);
private readonly TranslationOptions _options;
private readonly ILogger<TranslationRegistry> _logger;
public TranslationRegistry(IOptions<TranslationOptions> options, ILogger<TranslationRegistry> logger)
{
_options = options?.Value ?? new TranslationOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Loads all bundles from registered providers in priority order (lowest first).
/// </summary>
public async Task LoadAsync(IEnumerable<ITranslationBundleProvider> providers, CancellationToken ct)
{
var ordered = providers.OrderBy(p => p.Priority).ToList();
// Collect all available locales from all providers
var allLocales = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var provider in ordered)
{
var locales = await provider.GetAvailableLocalesAsync(ct).ConfigureAwait(false);
foreach (var locale in locales)
{
allLocales.Add(locale);
}
}
// Ensure default locale is always loaded
allLocales.Add(_options.DefaultLocale);
// Load bundles in priority order (lower first, higher overwrites).
// Locales within the same provider are independent, so load them concurrently
// and merge back in deterministic locale order.
foreach (var provider in ordered)
{
var loadTasks = allLocales
.OrderBy(locale => locale, StringComparer.OrdinalIgnoreCase)
.Select(locale => LoadProviderBundleAsync(provider, locale, ct))
.ToArray();
var results = await Task.WhenAll(loadTasks).ConfigureAwait(false);
foreach (var result in results)
{
if (result.Bundle.Count == 0)
{
continue;
}
MergeBundles(result.Locale, result.Bundle);
_logger.LogDebug(
"Loaded {Count} translations for locale {Locale} from provider (priority {Priority})",
result.Bundle.Count, result.Locale, provider.Priority);
}
}
var totalKeys = _store.Values.Sum(d => d.Count);
_logger.LogInformation(
"Translation registry loaded: {LocaleCount} locales, {TotalKeys} total keys",
_store.Count, totalKeys);
}
private async Task<ProviderLocaleBundle> LoadProviderBundleAsync(
ITranslationBundleProvider provider,
string locale,
CancellationToken ct)
{
try
{
var bundle = await provider.LoadAsync(locale, ct).ConfigureAwait(false);
return new ProviderLocaleBundle(locale, bundle);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to load translations for locale {Locale} from provider (priority {Priority})",
locale, provider.Priority);
return new ProviderLocaleBundle(
locale,
new Dictionary<string, string>(StringComparer.Ordinal));
}
}
/// <summary>
/// Merges a bundle into the store. Higher-priority values overwrite lower.
/// </summary>
public void MergeBundles(string locale, IReadOnlyDictionary<string, string> strings)
{
var dict = _store.GetOrAdd(locale, _ => new ConcurrentDictionary<string, string>(StringComparer.Ordinal));
foreach (var (key, value) in strings)
{
dict[key] = value;
}
}
/// <summary>
/// Translates a key with positional parameters.
/// Uses the current request locale from <see cref="LocaleContext"/> or the default.
/// </summary>
public string Translate(string key, object?[] args)
{
var locale = LocaleContext.Current ?? _options.DefaultLocale;
var template = Resolve(key, locale);
if (args.Length == 0)
{
return template;
}
try
{
return string.Format(CultureInfo.GetCultureInfo(locale), template, args);
}
catch (FormatException)
{
_logger.LogWarning("Format error translating key {Key} with {ArgCount} args for locale {Locale}",
key, args.Length, locale);
return template;
}
}
/// <summary>
/// Translates a key with named parameters.
/// Replaces {name} placeholders with formatted values.
/// </summary>
public string TranslateNamed(string key, (string Name, object Value)[] namedArgs)
{
var locale = LocaleContext.Current ?? _options.DefaultLocale;
var template = Resolve(key, locale);
foreach (var (name, value) in namedArgs)
{
var placeholder = $"{{{name}}}";
template = template.Replace(placeholder, FormatValue(value, locale), StringComparison.Ordinal);
}
return template;
}
/// <summary>
/// Returns all translations for a locale (merged from all providers).
/// </summary>
public IReadOnlyDictionary<string, string> GetBundle(string locale)
{
if (_store.TryGetValue(locale, out var dict))
{
return new Dictionary<string, string>(dict, StringComparer.Ordinal);
}
return new Dictionary<string, string>(StringComparer.Ordinal);
}
/// <summary>
/// Returns translations for a locale filtered by key prefix.
/// </summary>
public IReadOnlyDictionary<string, string> GetBundle(string locale, string namespacePrefix)
{
var prefix = namespacePrefix.EndsWith('.') ? namespacePrefix : namespacePrefix + ".";
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (_store.TryGetValue(locale, out var dict))
{
foreach (var (key, value) in dict)
{
if (key.StartsWith(prefix, StringComparison.Ordinal))
{
result[key] = value;
}
}
}
return result;
}
/// <summary>
/// Returns all loaded locales.
/// </summary>
public IReadOnlyList<string> GetLoadedLocales()
{
return _store.Keys.OrderBy(l => l).ToList();
}
/// <summary>
/// Resolves a key through the fallback chain:
/// requested locale -> language-only -> default locale -> key itself.
/// </summary>
private string Resolve(string key, string locale)
{
// 1. Try exact locale (e.g., "de-DE")
if (TryGetValue(locale, key, out var value))
{
return value;
}
// 2. Try language-only (e.g., "de")
var dashIndex = locale.IndexOf('-');
if (dashIndex > 0)
{
var languageOnly = locale[..dashIndex];
if (TryGetValue(languageOnly, key, out value))
{
return value;
}
}
// 3. Try default locale (e.g., "en-US")
if (!string.Equals(locale, _options.DefaultLocale, StringComparison.OrdinalIgnoreCase))
{
if (TryGetValue(_options.DefaultLocale, key, out value))
{
return value;
}
// 4. Try default language-only (e.g., "en")
var defaultDash = _options.DefaultLocale.IndexOf('-');
if (defaultDash > 0)
{
var defaultLang = _options.DefaultLocale[..defaultDash];
if (TryGetValue(defaultLang, key, out value))
{
return value;
}
}
}
// 5. Return key itself as fallback
if (_options.ReturnKeyWhenMissing)
{
return key;
}
return key;
}
private bool TryGetValue(string locale, string key, out string value)
{
value = string.Empty;
return _store.TryGetValue(locale, out var dict) && dict.TryGetValue(key, out value!);
}
private static string FormatValue(object value, string locale)
{
var culture = CultureInfo.GetCultureInfo(locale);
return value switch
{
null => string.Empty,
DateTime dt => dt.ToString("g", culture),
DateTimeOffset dto => dto.ToString("g", culture),
int i => i.ToString("N0", culture),
long l => l.ToString("N0", culture),
decimal d => d.ToString("N2", culture),
double dbl => dbl.ToString("N2", culture),
float f => f.ToString("N2", culture),
_ => value.ToString() ?? string.Empty
};
}
private sealed record ProviderLocaleBundle(string Locale, IReadOnlyDictionary<string, string> Bundle);
}