275 lines
9.3 KiB
C#
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);
|
|
}
|