search and ai stabilization work, localization stablized.
This commit is contained in:
251
src/__Libraries/StellaOps.Localization/TranslationRegistry.cs
Normal file
251
src/__Libraries/StellaOps.Localization/TranslationRegistry.cs
Normal file
@@ -0,0 +1,251 @@
|
||||
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)
|
||||
foreach (var provider in ordered)
|
||||
{
|
||||
foreach (var locale in allLocales)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bundle = await provider.LoadAsync(locale, ct).ConfigureAwait(false);
|
||||
if (bundle.Count > 0)
|
||||
{
|
||||
MergeBundles(locale, bundle);
|
||||
_logger.LogDebug(
|
||||
"Loaded {Count} translations for locale {Locale} from provider (priority {Priority})",
|
||||
bundle.Count, locale, provider.Priority);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to load translations for locale {Locale} from provider (priority {Priority})",
|
||||
locale, provider.Priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var totalKeys = _store.Values.Sum(d => d.Count);
|
||||
_logger.LogInformation(
|
||||
"Translation registry loaded: {LocaleCount} locales, {TotalKeys} total keys",
|
||||
_store.Count, totalKeys);
|
||||
}
|
||||
|
||||
/// <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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user