From 95e75b67eee9d2c20b59c63763cea8b42860fecb Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Thu, 9 Oct 2025 11:35:24 +0300 Subject: [PATCH 01/20] Add localization service and language provider interfaces Introduces ILanguageProvider and ILocalizationService interfaces to support resource management and runtime language switching. Also adds System.Reactive package to enable reactive culture change notifications. --- GenHub/GenHub.Core/GenHub.Core.csproj | 1 + .../Localization/ILanguageProvider.cs | 30 ++++++++++ .../Localization/ILocalizationService.cs | 60 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 GenHub/GenHub.Core/Interfaces/Localization/ILanguageProvider.cs create mode 100644 GenHub/GenHub.Core/Interfaces/Localization/ILocalizationService.cs diff --git a/GenHub/GenHub.Core/GenHub.Core.csproj b/GenHub/GenHub.Core/GenHub.Core.csproj index fb5901a66..57d5b1757 100644 --- a/GenHub/GenHub.Core/GenHub.Core.csproj +++ b/GenHub/GenHub.Core/GenHub.Core.csproj @@ -12,5 +12,6 @@ + diff --git a/GenHub/GenHub.Core/Interfaces/Localization/ILanguageProvider.cs b/GenHub/GenHub.Core/Interfaces/Localization/ILanguageProvider.cs new file mode 100644 index 000000000..6b62aa1a7 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Localization/ILanguageProvider.cs @@ -0,0 +1,30 @@ +using System.Globalization; +using System.Resources; + +namespace GenHub.Core.Interfaces.Localization; + +/// +/// Manages resource discovery and resource manager provisioning for localization. +/// +public interface ILanguageProvider +{ + /// + /// Discovers all available satellite assembly cultures. + /// + /// A task that yields a list of available cultures. + Task> DiscoverAvailableLanguages(); + + /// + /// Gets a ResourceManager for the specified base name. + /// + /// The base name of the resource (e.g., "GenHub.Core.Resources.Strings"). + /// The ResourceManager instance. + ResourceManager GetResourceManager(string baseName); + + /// + /// Validates that a culture is available in the application. + /// + /// The culture to validate. + /// True if the culture is available; otherwise, false. + bool ValidateCulture(CultureInfo culture); +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Localization/ILocalizationService.cs b/GenHub/GenHub.Core/Interfaces/Localization/ILocalizationService.cs new file mode 100644 index 000000000..bef16bf48 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Localization/ILocalizationService.cs @@ -0,0 +1,60 @@ +using System.Globalization; + +namespace GenHub.Core.Interfaces.Localization; + +/// +/// Provides localization services for retrieving translated strings with runtime language switching. +/// +public interface ILocalizationService +{ + /// + /// Gets or sets the currently active culture. + /// + CultureInfo CurrentCulture { get; set; } + + /// + /// Gets all available cultures (languages) in the application. + /// + IReadOnlyList AvailableCultures { get; } + + /// + /// Gets an observable that emits whenever the language/culture changes. + /// Subscribe to this for reactive UI updates. + /// + IObservable CultureChanged { get; } + + /// + /// Gets a localized string for the specified key. + /// + /// The resource key. + /// The localized string, or the key if not found. + string GetString(string key); + + /// + /// Gets a localized string with parameter substitution. + /// + /// The resource key. + /// Arguments for string.Format. + /// The formatted localized string. + string GetString(string key, params object[] args); + + /// + /// Changes the active culture/language asynchronously. + /// + /// The culture to activate. + /// A task representing the asynchronous operation. + Task SetCulture(CultureInfo culture); + + /// + /// Changes the active culture by language code (e.g., "en", "de") asynchronously. + /// + /// The language code. + /// A task representing the asynchronous operation. + Task SetCulture(string cultureName); + + /// + /// Refreshes the list of available cultures by rescanning for languages. + /// + /// A task representing the asynchronous operation. + Task RefreshAvailableCultures(); +} \ No newline at end of file From a1e78aab01854fedf430b204924bb3712a0ec549 Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Thu, 9 Oct 2025 11:43:36 +0300 Subject: [PATCH 02/20] Add localization options and language provider Introduces LocalizationOptions for configuring localization behavior and LanguageProvider for discovering available languages, managing resource managers, and validating cultures. These additions support improved localization management and extensibility in the application. --- .../Localization/LocalizationOptions.cs | 45 ++++ .../Services/Localization/LanguageProvider.cs | 198 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 GenHub/GenHub.Core/Models/Localization/LocalizationOptions.cs create mode 100644 GenHub/GenHub.Core/Services/Localization/LanguageProvider.cs diff --git a/GenHub/GenHub.Core/Models/Localization/LocalizationOptions.cs b/GenHub/GenHub.Core/Models/Localization/LocalizationOptions.cs new file mode 100644 index 000000000..3bf5756ed --- /dev/null +++ b/GenHub/GenHub.Core/Models/Localization/LocalizationOptions.cs @@ -0,0 +1,45 @@ +namespace GenHub.Core.Models.Localization; + +/// +/// Configuration options for the localization system. +/// +public class LocalizationOptions +{ + /// + /// Gets or sets the default culture code (e.g., "en", "en-US"). + /// + /// + /// This is the culture that will be used when the application starts + /// if no user preference is set. + /// + public string DefaultCulture { get; set; } = "en"; + + /// + /// Gets or sets the fallback culture code used when a translation is missing. + /// + /// + /// When a translation key is not found in the current culture, + /// the system will attempt to use this culture's translation. + /// + public string FallbackCulture { get; set; } = "en"; + + /// + /// Gets or sets a value indicating whether to log missing translations. + /// + /// + /// When enabled, missing translation keys will be logged as warnings. + /// This is useful during development to identify untranslated content. + /// Default is true to help developers identify missing translations. + /// + public bool LogMissingTranslations { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to throw an exception when a translation is missing. + /// + /// + /// When enabled, requesting a missing translation key will throw an exception. + /// This is typically disabled in production to allow graceful degradation. + /// Default is false to prevent application crashes. + /// + public bool ThrowOnMissingTranslation { get; set; } = false; +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Services/Localization/LanguageProvider.cs b/GenHub/GenHub.Core/Services/Localization/LanguageProvider.cs new file mode 100644 index 000000000..f1bc44879 --- /dev/null +++ b/GenHub/GenHub.Core/Services/Localization/LanguageProvider.cs @@ -0,0 +1,198 @@ +using System.Globalization; +using System.Reflection; +using System.Resources; +using GenHub.Core.Interfaces.Localization; +using Microsoft.Extensions.Logging; + +namespace GenHub.Core.Services.Localization; + +/// +/// Provides language discovery and resource manager provisioning for localization. +/// +public class LanguageProvider : ILanguageProvider +{ + private readonly ILogger _logger; + private readonly Dictionary _resourceManagers; + private IReadOnlyList? _cachedCultures; + private readonly object _lock = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance. + public LanguageProvider(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _resourceManagers = new Dictionary(); + } + + /// + public async Task> DiscoverAvailableLanguages() + { + return await Task.Run(() => + { + lock (_lock) + { + if (_cachedCultures != null) + { + _logger.LogDebug("Returning cached cultures: {Count} languages", _cachedCultures.Count); + return _cachedCultures; + } + + var cultures = new List(); + var assembly = Assembly.GetExecutingAssembly(); + + try + { + // Add default/invariant culture (English) + var defaultCulture = CultureInfo.GetCultureInfo("en"); + cultures.Add(defaultCulture); + _logger.LogDebug("Added default culture: {Culture}", defaultCulture.Name); + + // Discover satellite assemblies + var satelliteCultures = DiscoverSatelliteAssemblyCultures(assembly); + cultures.AddRange(satelliteCultures); + + // Remove duplicates and sort by English name + _cachedCultures = cultures + .DistinctBy(c => c.TwoLetterISOLanguageName) + .OrderBy(c => c.EnglishName) + .ToList() + .AsReadOnly(); + + _logger.LogInformation( + "Discovered {Count} available languages: {Languages}", + _cachedCultures.Count, + string.Join(", ", _cachedCultures.Select(c => c.Name))); + + return _cachedCultures; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error discovering available languages"); + + // Return at least the default culture + _cachedCultures = new List { CultureInfo.GetCultureInfo("en") }.AsReadOnly(); + return _cachedCultures; + } + } + }); + } + + /// + public ResourceManager GetResourceManager(string baseName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseName, nameof(baseName)); + + lock (_lock) + { + if (_resourceManagers.TryGetValue(baseName, out var existingManager)) + { + return existingManager; + } + + try + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceManager = new ResourceManager(baseName, assembly); + + _resourceManagers[baseName] = resourceManager; + _logger.LogDebug("Created ResourceManager for base name: {BaseName}", baseName); + + return resourceManager; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating ResourceManager for base name: {BaseName}", baseName); + throw; + } + } + } + + /// + public bool ValidateCulture(CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(culture, nameof(culture)); + + try + { + var assembly = Assembly.GetExecutingAssembly(); + + // The invariant/default culture is always valid + if (culture.Equals(CultureInfo.InvariantCulture) || + culture.TwoLetterISOLanguageName.Equals("en", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Try to get the satellite assembly for this culture + try + { + var satelliteAssembly = assembly.GetSatelliteAssembly(culture); + return satelliteAssembly != null; + } + catch (FileNotFoundException) + { + // Satellite assembly doesn't exist for this culture + return false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error validating culture: {Culture}", culture.Name); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error validating culture: {Culture}", culture.Name); + return false; + } + } + + /// + /// Discovers cultures that have satellite assemblies. + /// + /// The assembly to scan. + /// A list of cultures with satellite assemblies. + private List DiscoverSatelliteAssemblyCultures(Assembly assembly) + { + var satelliteCultures = new List(); + + try + { + // Get all cultures and check if satellite assembly exists + var allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures) + .Where(c => !string.IsNullOrEmpty(c.Name)); // Skip invariant culture + + foreach (var culture in allCultures) + { + try + { + // Attempt to get satellite assembly + var satelliteAssembly = assembly.GetSatelliteAssembly(culture); + if (satelliteAssembly != null) + { + satelliteCultures.Add(culture); + _logger.LogDebug("Found satellite assembly for culture: {Culture}", culture.Name); + } + } + catch (FileNotFoundException) + { + // This is expected for cultures without satellite assemblies + continue; + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Error checking satellite assembly for culture: {Culture}", culture.Name); + continue; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error during satellite assembly discovery"); + } + + return satelliteCultures; + } +} \ No newline at end of file From 9aed532740da476447c12bf05823af2d5da7eea0 Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Thu, 9 Oct 2025 12:38:42 +0300 Subject: [PATCH 03/20] Add localization service and extension methods Introduces LocalizationService for runtime language switching, resource management, and reactive culture updates. Adds LocalizationExtensions with culture-aware formatting and utility methods for dates, numbers, currency, percentages, file sizes, pluralization, and culture info. --- .../Localization/LocalizationExtensions.cs | 299 +++++++++++++++++ .../Localization/LocalizationService.cs | 300 ++++++++++++++++++ 2 files changed, 599 insertions(+) create mode 100644 GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs create mode 100644 GenHub/GenHub.Core/Services/Localization/LocalizationService.cs diff --git a/GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs b/GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs new file mode 100644 index 000000000..945631b87 --- /dev/null +++ b/GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs @@ -0,0 +1,299 @@ +using System.Globalization; +using GenHub.Core.Interfaces.Localization; + +namespace GenHub.Core.Extensions.Localization; + +/// +/// Extension methods for localization services and culture-aware formatting. +/// +public static class LocalizationExtensions +{ + /// + /// Tries to get a localized string, returning a success indicator. + /// + /// The localization service. + /// The resource key. + /// The localized string if found; otherwise, null. + /// True if the string was found; otherwise, false. + public static bool TryGetString( + this ILocalizationService service, + string key, + out string? value) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(key)); + + try + { + var result = service.GetString(key); + + // Check if we got a missing translation marker + if (result.StartsWith('[') && result.EndsWith(']')) + { + value = null; + return false; + } + + value = result; + return true; + } + catch + { + value = null; + return false; + } + } + + /// + /// Formats a date using the current culture's date format. + /// + /// The localization service. + /// The date to format. + /// Optional format string (defaults to short date pattern). + /// The formatted date string. + public static string FormatDate( + this ILocalizationService service, + DateTime date, + string? format = null) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + var culture = service.CurrentCulture; + return string.IsNullOrWhiteSpace(format) + ? date.ToString("d", culture) // Short date pattern + : date.ToString(format, culture); + } + + /// + /// Formats a date and time using the current culture's format. + /// + /// The localization service. + /// The date and time to format. + /// Optional format string (defaults to short date + time pattern). + /// The formatted date and time string. + public static string FormatDateTime( + this ILocalizationService service, + DateTime dateTime, + string? format = null) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + var culture = service.CurrentCulture; + return string.IsNullOrWhiteSpace(format) + ? dateTime.ToString("g", culture) // Short date + time pattern + : dateTime.ToString(format, culture); + } + + /// + /// Formats a time using the current culture's time format. + /// + /// The localization service. + /// The time to format. + /// Optional format string (defaults to short time pattern). + /// The formatted time string. + public static string FormatTime( + this ILocalizationService service, + DateTime time, + string? format = null) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + var culture = service.CurrentCulture; + return string.IsNullOrWhiteSpace(format) + ? time.ToString("t", culture) // Short time pattern + : time.ToString(format, culture); + } + + /// + /// Formats a number using the current culture's number format. + /// + /// The localization service. + /// The number to format. + /// Optional number of decimal places. + /// The formatted number string. + public static string FormatNumber( + this ILocalizationService service, + double number, + int? decimals = null) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + var culture = service.CurrentCulture; + return decimals.HasValue + ? number.ToString($"N{decimals.Value}", culture) + : number.ToString("N", culture); + } + + /// + /// Formats a number using the current culture's number format. + /// + /// The localization service. + /// The number to format. + /// Optional number of decimal places. + /// The formatted number string. + public static string FormatNumber( + this ILocalizationService service, + decimal number, + int? decimals = null) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + var culture = service.CurrentCulture; + return decimals.HasValue + ? number.ToString($"N{decimals.Value}", culture) + : number.ToString("N", culture); + } + + /// + /// Formats a currency value using the current culture's currency format. + /// + /// The localization service. + /// The amount to format. + /// Optional ISO currency code (e.g., "USD", "EUR"). If null, uses culture's default. + /// The formatted currency string. + public static string FormatCurrency( + this ILocalizationService service, + decimal amount, + string? currencyCode = null) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + var culture = service.CurrentCulture; + + if (!string.IsNullOrWhiteSpace(currencyCode)) + { + // Create a region info to get the currency symbol + try + { + var regions = CultureInfo.GetCultures(CultureTypes.SpecificCultures) + .Select(c => new RegionInfo(c.Name)) + .FirstOrDefault(r => r.ISOCurrencySymbol.Equals(currencyCode, StringComparison.OrdinalIgnoreCase)); + + if (regions != null) + { + return amount.ToString("C", culture); + } + } + catch + { + // Fall through to default formatting + } + } + + return amount.ToString("C", culture); + } + + /// + /// Formats a percentage using the current culture's percentage format. + /// + /// The localization service. + /// The value to format (e.g., 0.85 for 85%). + /// Optional number of decimal places. + /// The formatted percentage string. + public static string FormatPercentage( + this ILocalizationService service, + double value, + int? decimals = null) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + var culture = service.CurrentCulture; + return decimals.HasValue + ? value.ToString($"P{decimals.Value}", culture) + : value.ToString("P", culture); + } + + /// + /// Formats a file size in a human-readable format using current culture. + /// + /// The localization service. + /// The size in bytes. + /// The formatted file size string (e.g., "1.5 MB"). + public static string FormatFileSize( + this ILocalizationService service, + long bytes) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + double size = bytes; + int order = 0; + + while (size >= 1024 && order < sizes.Length - 1) + { + order++; + size /= 1024; + } + + var culture = service.CurrentCulture; + return $"{size.ToString("0.##", culture)} {sizes[order]}"; + } + + /// + /// Gets a formatted string for plural forms based on count. + /// + /// The localization service. + /// The count to determine plurality. + /// The resource key for zero items. + /// The resource key for one item. + /// The resource key for many items. + /// The appropriate pluralized string. + public static string GetPluralString( + this ILocalizationService service, + int count, + string zeroKey, + string oneKey, + string manyKey) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + ArgumentException.ThrowIfNullOrWhiteSpace(zeroKey, nameof(zeroKey)); + ArgumentException.ThrowIfNullOrWhiteSpace(oneKey, nameof(oneKey)); + ArgumentException.ThrowIfNullOrWhiteSpace(manyKey, nameof(manyKey)); + + var key = count switch + { + 0 => zeroKey, + 1 => oneKey, + _ => manyKey + }; + + return service.GetString(key, count); + } + + /// + /// Checks if a culture is Right-to-Left (RTL). + /// + /// The localization service. + /// True if the current culture is RTL; otherwise, false. + public static bool IsRightToLeft(this ILocalizationService service) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + var culture = service.CurrentCulture; + return culture.TextInfo.IsRightToLeft; + } + + /// + /// Gets the display name of the current culture in its native language. + /// + /// The localization service. + /// The native name of the current culture. + public static string GetCurrentCultureNativeName(this ILocalizationService service) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + return service.CurrentCulture.NativeName; + } + + /// + /// Gets the display name of the current culture in English. + /// + /// The localization service. + /// The English name of the current culture. + public static string GetCurrentCultureEnglishName(this ILocalizationService service) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + + return service.CurrentCulture.EnglishName; + } +} \ No newline at end of file diff --git a/GenHub/GenHub.Core/Services/Localization/LocalizationService.cs b/GenHub/GenHub.Core/Services/Localization/LocalizationService.cs new file mode 100644 index 000000000..72206dc6d --- /dev/null +++ b/GenHub/GenHub.Core/Services/Localization/LocalizationService.cs @@ -0,0 +1,300 @@ +using System.Globalization; +using System.Reactive.Subjects; +using GenHub.Core.Interfaces.Localization; +using GenHub.Core.Models.Localization; +using Microsoft.Extensions.Logging; + +namespace GenHub.Core.Services.Localization; + +/// +/// Provides localization services with runtime language switching and reactive updates. +/// +public class LocalizationService : ILocalizationService, IDisposable +{ + private readonly ILanguageProvider _languageProvider; + private readonly ILogger _logger; + private readonly LocalizationOptions _options; + private readonly Subject _cultureChangedSubject; + private readonly object _lock = new(); + private CultureInfo _currentCulture; + private IReadOnlyList? _availableCultures; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The language provider for resource management. + /// The logger instance. + /// The localization options. + public LocalizationService( + ILanguageProvider languageProvider, + ILogger logger, + LocalizationOptions? options = null) + { + _languageProvider = languageProvider ?? throw new ArgumentNullException(nameof(languageProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options ?? new LocalizationOptions(); + _cultureChangedSubject = new Subject(); + + // Initialize with default culture + _currentCulture = GetCultureFromString(_options.DefaultCulture); + SetThreadCulture(_currentCulture); + + _logger.LogInformation("LocalizationService initialized with culture: {Culture}", _currentCulture.Name); + } + + /// + public CultureInfo CurrentCulture + { + get + { + lock (_lock) + { + return _currentCulture; + } + } + set + { + SetCulture(value).GetAwaiter().GetResult(); + } + } + + /// + public IReadOnlyList AvailableCultures + { + get + { + if (_availableCultures != null) + { + return _availableCultures; + } + + // Synchronously get cached or discover + _availableCultures = _languageProvider.DiscoverAvailableLanguages().GetAwaiter().GetResult(); + return _availableCultures; + } + } + + /// + public IObservable CultureChanged => _cultureChangedSubject; + + /// + public string GetString(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(key)); + + try + { + // For Phase 1, we don't have actual .resx files yet + // This is infrastructure only, so we return a placeholder + var resourceManager = _languageProvider.GetResourceManager("GenHub.Core.Resources.Strings"); + + lock (_lock) + { + var value = resourceManager.GetString(key, _currentCulture); + + if (string.IsNullOrEmpty(value)) + { + return HandleMissingTranslation(key); + } + + return value; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving resource string for key: {Key}", key); + return HandleMissingTranslation(key); + } + } + + /// + public string GetString(string key, params object[] args) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(key)); + + try + { + var format = GetString(key); + + if (args == null || args.Length == 0) + { + return format; + } + + return string.Format(_currentCulture, format, args); + } + catch (FormatException ex) + { + _logger.LogError( + ex, + "Format error for key '{Key}' with {ArgCount} arguments", + key, + args?.Length ?? 0); + + return HandleMissingTranslation(key); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error formatting resource string for key: {Key}", key); + return HandleMissingTranslation(key); + } + } + + /// + public async Task SetCulture(CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(culture, nameof(culture)); + + await Task.Run(() => + { + lock (_lock) + { + try + { + // Validate the culture is available + if (!_languageProvider.ValidateCulture(culture)) + { + _logger.LogWarning( + "Culture '{Culture}' is not available. Falling back to '{Fallback}'", + culture.Name, + _options.FallbackCulture); + + culture = GetCultureFromString(_options.FallbackCulture); + } + + // Set the culture + _currentCulture = culture; + SetThreadCulture(culture); + + _logger.LogInformation("Culture changed to: {Culture}", culture.Name); + + // Notify observers + _cultureChangedSubject.OnNext(culture); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error setting culture to: {Culture}", culture.Name); + throw; + } + } + }); + } + + /// + public async Task SetCulture(string cultureName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(cultureName, nameof(cultureName)); + + try + { + var culture = GetCultureFromString(cultureName); + await SetCulture(culture); + } + catch (CultureNotFoundException ex) + { + _logger.LogError(ex, "Invalid culture name: {CultureName}", cultureName); + throw new ArgumentException($"Invalid culture name: {cultureName}", nameof(cultureName), ex); + } + } + + /// + public async Task RefreshAvailableCultures() + { + _logger.LogDebug("Refreshing available cultures"); + + _availableCultures = await _languageProvider.DiscoverAvailableLanguages(); + + _logger.LogInformation( + "Available cultures refreshed. Found {Count} cultures", + _availableCultures.Count); + } + + /// + /// Sets the thread culture for both CurrentCulture and CurrentUICulture. + /// + /// The culture to set. + private void SetThreadCulture(CultureInfo culture) + { + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + Thread.CurrentThread.CurrentCulture = culture; + Thread.CurrentThread.CurrentUICulture = culture; + } + + /// + /// Gets a CultureInfo from a culture string, with error handling. + /// + /// The culture name. + /// The CultureInfo instance. + private CultureInfo GetCultureFromString(string cultureName) + { + try + { + return CultureInfo.GetCultureInfo(cultureName); + } + catch (CultureNotFoundException ex) + { + _logger.LogWarning( + ex, + "Culture '{Culture}' not found. Using invariant culture", + cultureName); + + return CultureInfo.InvariantCulture; + } + } + + /// + /// Handles missing translation keys according to configuration. + /// + /// The missing translation key. + /// A placeholder string for the missing translation. + private string HandleMissingTranslation(string key) + { + if (_options.LogMissingTranslations) + { + _logger.LogWarning( + "Missing translation for key '{Key}' in culture '{Culture}'", + key, + _currentCulture.Name); + } + + if (_options.ThrowOnMissingTranslation) + { + throw new InvalidOperationException( + $"Missing translation for key '{key}' in culture '{_currentCulture.Name}'"); + } + + // In development, return key with markers; in production, return clean key + return _options.LogMissingTranslations ? $"[{key}]" : key; + } + + /// + /// Disposes the service and releases resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes the service and releases resources. + /// + /// True if disposing managed resources. + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + _cultureChangedSubject?.OnCompleted(); + _cultureChangedSubject?.Dispose(); + } + + _disposed = true; + } +} \ No newline at end of file From d5094ce499238feebd1d30f4af35c42d774f7f11 Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Thu, 9 Oct 2025 12:43:23 +0300 Subject: [PATCH 04/20] Add localization services DI module Introduces LocalizationServicesModule for registering localization-related services and options via dependency injection. Provides extension methods for IServiceCollection to add localization services with optional configuration, Added System.Reactive to Directory.Packages.props and removed explicit version from GenHub.Core.csproj to centralize package version management. --- GenHub/Directory.Packages.props | 1 + GenHub/GenHub.Core/GenHub.Core.csproj | 2 +- .../LocalizationServicesModule.cs | 54 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationServicesModule.cs diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index e0138d49c..0a4d1f5bd 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -27,6 +27,7 @@ + diff --git a/GenHub/GenHub.Core/GenHub.Core.csproj b/GenHub/GenHub.Core/GenHub.Core.csproj index 57d5b1757..3ef5976e0 100644 --- a/GenHub/GenHub.Core/GenHub.Core.csproj +++ b/GenHub/GenHub.Core/GenHub.Core.csproj @@ -12,6 +12,6 @@ - + diff --git a/GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationServicesModule.cs b/GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationServicesModule.cs new file mode 100644 index 000000000..58eb843c8 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/DependencyInjection/LocalizationServicesModule.cs @@ -0,0 +1,54 @@ +using System; +using GenHub.Core.Interfaces.Localization; +using GenHub.Core.Models.Localization; +using GenHub.Core.Services.Localization; +using Microsoft.Extensions.DependencyInjection; + +namespace GenHub.Infrastructure.DependencyInjection; + +/// +/// Provides dependency injection registration for localization services. +/// +public static class LocalizationServicesModule +{ + /// + /// Adds localization services to the service collection. + /// + /// The service collection. + /// Optional localization options. If null, default options will be used. + /// The updated service collection. + public static IServiceCollection AddLocalizationServices( + this IServiceCollection services, + LocalizationOptions? options = null) + { + // Register localization options as singleton + var localizationOptions = options ?? new LocalizationOptions(); + services.AddSingleton(localizationOptions); + + // Register language provider as singleton + services.AddSingleton(); + + // Register localization service as singleton + services.AddSingleton(); + + return services; + } + + /// + /// Adds localization services to the service collection with configuration action. + /// + /// The service collection. + /// Action to configure localization options. + /// The updated service collection. + public static IServiceCollection AddLocalizationServices( + this IServiceCollection services, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(configureOptions, nameof(configureOptions)); + + var options = new LocalizationOptions(); + configureOptions(options); + + return services.AddLocalizationServices(options); + } +} \ No newline at end of file From a79fffd92e5a91d391b3843985deac9fdd731c23 Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Sat, 11 Oct 2025 02:39:07 +0300 Subject: [PATCH 05/20] Add unit tests for localization services Introduces LanguageProviderTests and LocalizationServiceTests to verify the behavior of language discovery, culture validation, resource management, and localization service features including culture changes, string retrieval, and error handling. --- .../Localization/LanguageProviderTests.cs | 185 ++++++++ .../Localization/LocalizationServiceTests.cs | 407 ++++++++++++++++++ 2 files changed, 592 insertions(+) create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Services/Localization/LanguageProviderTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Services/Localization/LocalizationServiceTests.cs diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Services/Localization/LanguageProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Services/Localization/LanguageProviderTests.cs new file mode 100644 index 000000000..e6743baff --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Services/Localization/LanguageProviderTests.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using GenHub.Core.Services.Localization; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Services.Localization; + +/// +/// Tests for . +/// +public class LanguageProviderTests +{ + private readonly Mock> _mockLogger; + private readonly LanguageProvider _provider; + + public LanguageProviderTests() + { + _mockLogger = new Mock>(); + _provider = new LanguageProvider(_mockLogger.Object); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new LanguageProvider(null!)); + } + + [Fact] + public async Task DiscoverAvailableLanguages_ReturnsAtLeastEnglish() + { + // Act + var result = await _provider.DiscoverAvailableLanguages(); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Contains(result, c => c.TwoLetterISOLanguageName == "en"); + } + + [Fact] + public async Task DiscoverAvailableLanguages_CachesResults() + { + // Act + var result1 = await _provider.DiscoverAvailableLanguages(); + var result2 = await _provider.DiscoverAvailableLanguages(); + + // Assert + Assert.Same(result1, result2); + } + + [Fact] + public async Task DiscoverAvailableLanguages_ReturnsDistinctCultures() + { + // Act + var result = await _provider.DiscoverAvailableLanguages(); + + // Assert + var languageCodes = result.Select(c => c.TwoLetterISOLanguageName).ToList(); + var distinctCodes = languageCodes.Distinct().ToList(); + Assert.Equal(distinctCodes.Count, languageCodes.Count); + } + + [Fact] + public async Task DiscoverAvailableLanguages_SortsResultsByEnglishName() + { + // Act + var result = await _provider.DiscoverAvailableLanguages(); + + // Assert + var englishNames = result.Select(c => c.EnglishName).ToList(); + var sortedNames = englishNames.OrderBy(n => n).ToList(); + Assert.Equal(sortedNames, englishNames); + } + + [Fact] + public void GetResourceManager_WithValidBaseName_ReturnsResourceManager() + { + // Arrange + var baseName = "GenHub.Core.Resources.Strings"; + + // Act + var result = _provider.GetResourceManager(baseName); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetResourceManager_WithSameBaseName_ReturnsSameInstance() + { + // Arrange + var baseName = "GenHub.Core.Resources.Strings"; + + // Act + var result1 = _provider.GetResourceManager(baseName); + var result2 = _provider.GetResourceManager(baseName); + + // Assert + Assert.Same(result1, result2); + } + + [Fact] + public void GetResourceManager_WithNullBaseName_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => _provider.GetResourceManager(null!)); + } + + [Fact] + public void GetResourceManager_WithEmptyBaseName_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => _provider.GetResourceManager(string.Empty)); + } + + [Fact] + public void GetResourceManager_WithWhitespaceBaseName_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => _provider.GetResourceManager(" ")); + } + + [Fact] + public void ValidateCulture_WithNullCulture_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _provider.ValidateCulture(null!)); + } + + [Fact] + public void ValidateCulture_WithEnglishCulture_ReturnsTrue() + { + // Arrange + var culture = CultureInfo.GetCultureInfo("en"); + + // Act + var result = _provider.ValidateCulture(culture); + + // Assert + Assert.True(result); + } + + [Fact] + public void ValidateCulture_WithEnglishUSCulture_ReturnsTrue() + { + // Arrange + var culture = CultureInfo.GetCultureInfo("en-US"); + + // Act + var result = _provider.ValidateCulture(culture); + + // Assert + Assert.True(result); + } + + [Fact] + public void ValidateCulture_WithInvariantCulture_ReturnsTrue() + { + // Arrange + var culture = CultureInfo.InvariantCulture; + + // Act + var result = _provider.ValidateCulture(culture); + + // Assert + Assert.True(result); + } + + [Fact] + public void ValidateCulture_WithUnavailableCulture_ReturnsFalse() + { + // Arrange + // Use a culture that definitely won't have a satellite assembly + var culture = CultureInfo.GetCultureInfo("zu-ZA"); // Zulu + + // Act + var result = _provider.ValidateCulture(culture); + + // Assert + // This will be false unless satellite assembly exists + Assert.False(result); + } +} \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Services/Localization/LocalizationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Services/Localization/LocalizationServiceTests.cs new file mode 100644 index 000000000..2f7dcd22f --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Services/Localization/LocalizationServiceTests.cs @@ -0,0 +1,407 @@ +using System.Globalization; +using System.Reactive.Linq; +using GenHub.Core.Interfaces.Localization; +using GenHub.Core.Models.Localization; +using GenHub.Core.Services.Localization; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Services.Localization; + +/// +/// Tests for . +/// +public class LocalizationServiceTests : IDisposable +{ + private readonly Mock _mockLanguageProvider; + private readonly Mock> _mockLogger; + private readonly LocalizationService _service; + private readonly LocalizationOptions _options; + + public LocalizationServiceTests() + { + _mockLanguageProvider = new Mock(); + _mockLogger = new Mock>(); + _options = new LocalizationOptions + { + DefaultCulture = "en", + FallbackCulture = "en", + LogMissingTranslations = true, + ThrowOnMissingTranslation = false + }; + + // Setup default behavior for language provider + var availableCultures = new List + { + CultureInfo.GetCultureInfo("en"), + CultureInfo.GetCultureInfo("de"), + CultureInfo.GetCultureInfo("fr") + }; + + _mockLanguageProvider + .Setup(p => p.DiscoverAvailableLanguages()) + .ReturnsAsync(availableCultures.AsReadOnly()); + + _mockLanguageProvider + .Setup(p => p.ValidateCulture(It.IsAny())) + .Returns(c => + c.TwoLetterISOLanguageName == "en" || + c.TwoLetterISOLanguageName == "de" || + c.TwoLetterISOLanguageName == "fr"); + + _service = new LocalizationService(_mockLanguageProvider.Object, _mockLogger.Object, _options); + } + + public void Dispose() + { + _service?.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public void Constructor_WithNullLanguageProvider_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new LocalizationService(null!, _mockLogger.Object, _options)); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new LocalizationService(_mockLanguageProvider.Object, null!, _options)); + } + + [Fact] + public void Constructor_WithNullOptions_UsesDefaultOptions() + { + // Act + using var service = new LocalizationService(_mockLanguageProvider.Object, _mockLogger.Object, null); + + // Assert + Assert.NotNull(service); + Assert.NotNull(service.CurrentCulture); + } + + [Fact] + public void Constructor_SetsDefaultCulture() + { + // Assert + Assert.Equal("en", _service.CurrentCulture.TwoLetterISOLanguageName); + } + + [Fact] + public void CurrentCulture_Get_ReturnsCurrentCulture() + { + // Act + var culture = _service.CurrentCulture; + + // Assert + Assert.NotNull(culture); + Assert.Equal("en", culture.TwoLetterISOLanguageName); + } + + [Fact] + public async Task CurrentCulture_Set_ChangesCurrentCulture() + { + // Arrange + var germanCulture = CultureInfo.GetCultureInfo("de"); + + // Act + _service.CurrentCulture = germanCulture; + await Task.Delay(100); // Give async operation time to complete + + // Assert + Assert.Equal("de", _service.CurrentCulture.TwoLetterISOLanguageName); + } + + [Fact] + public void AvailableCultures_ReturnsDiscoveredCultures() + { + // Act + var cultures = _service.AvailableCultures; + + // Assert + Assert.NotNull(cultures); + Assert.Contains(cultures, c => c.TwoLetterISOLanguageName == "en"); + Assert.Contains(cultures, c => c.TwoLetterISOLanguageName == "de"); + Assert.Contains(cultures, c => c.TwoLetterISOLanguageName == "fr"); + } + + [Fact] + public void CultureChanged_IsObservable() + { + // Act & Assert + Assert.NotNull(_service.CultureChanged); + } + + [Fact] + public async Task SetCulture_WithValidCulture_UpdatesCurrentCulture() + { + // Arrange + var germanCulture = CultureInfo.GetCultureInfo("de"); + + // Act + await _service.SetCulture(germanCulture); + + // Assert + Assert.Equal("de", _service.CurrentCulture.TwoLetterISOLanguageName); + } + + [Fact] + public async Task SetCulture_WithValidCulture_NotifiesObservers() + { + // Arrange + var germanCulture = CultureInfo.GetCultureInfo("de"); + CultureInfo? notifiedCulture = null; + using var subscription = _service.CultureChanged.Subscribe(c => notifiedCulture = c); + + // Act + await _service.SetCulture(germanCulture); + + // Assert + Assert.NotNull(notifiedCulture); + Assert.Equal("de", notifiedCulture.TwoLetterISOLanguageName); + } + + [Fact] + public async Task SetCulture_WithInvalidCulture_FallsBackToDefaultCulture() + { + // Arrange + var invalidCulture = CultureInfo.GetCultureInfo("zu-ZA"); // Zulu + + // Act + await _service.SetCulture(invalidCulture); + + // Assert - should fall back to English + Assert.Equal("en", _service.CurrentCulture.TwoLetterISOLanguageName); + } + + [Fact] + public async Task SetCulture_WithNullCulture_ThrowsArgumentNullException() + { + // Act & Assert + await Assert.ThrowsAsync(() => _service.SetCulture((CultureInfo)null!)); + } + + [Fact] + public async Task SetCulture_ByCultureName_UpdatesCurrentCulture() + { + // Act + await _service.SetCulture("de"); + + // Assert + Assert.Equal("de", _service.CurrentCulture.TwoLetterISOLanguageName); + } + + [Fact] + public async Task SetCulture_WithNullCultureName_ThrowsArgumentException() + { + // Act & Assert + await Assert.ThrowsAsync(() => _service.SetCulture((string)null!)); + } + + [Fact] + public async Task SetCulture_WithEmptyCultureName_ThrowsArgumentException() + { + // Act & Assert + await Assert.ThrowsAsync(() => _service.SetCulture(string.Empty)); + } + + [Fact] + public async Task SetCulture_WithWhitespaceCultureName_ThrowsArgumentException() + { + // Act & Assert + await Assert.ThrowsAsync(() => _service.SetCulture(" ")); + } + + [Fact] + public async Task SetCulture_WithInvalidCultureName_FallsBackToDefaultCulture() + { + // Act + await _service.SetCulture("invalid-culture"); + + // Assert - should fall back to English since invalid culture + Assert.Equal("en", _service.CurrentCulture.TwoLetterISOLanguageName); + } + + [Fact] + public void GetString_WithNullKey_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => _service.GetString(null!)); + } + + [Fact] + public void GetString_WithEmptyKey_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => _service.GetString(string.Empty)); + } + + [Fact] + public void GetString_WithWhitespaceKey_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => _service.GetString(" ")); + } + + [Fact] + public void GetString_WithMissingKey_ReturnsKeyWithMarker() + { + // Arrange + var key = "NonExistentKey"; + + // Act + var result = _service.GetString(key); + + // Assert - when LogMissingTranslations is true, returns [key] + Assert.Contains(key, result); + } + + [Fact] + public void GetString_WithParameters_FormatsString() + { + // Arrange + var key = "TestKey"; + var args = new object[] { "test", 123 }; + + // Act + var result = _service.GetString(key, args); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetString_WithNullParameters_ReturnsUnformattedString() + { + // Arrange + var key = "TestKey"; + + // Act + var result = _service.GetString(key, null!); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public async Task RefreshAvailableCultures_UpdatesAvailableCultures() + { + // Arrange + var newCultures = new List + { + CultureInfo.GetCultureInfo("en"), + CultureInfo.GetCultureInfo("es") + }; + + _mockLanguageProvider + .Setup(p => p.DiscoverAvailableLanguages()) + .ReturnsAsync(newCultures.AsReadOnly()); + + // Act + await _service.RefreshAvailableCultures(); + + // Assert + var cultures = _service.AvailableCultures; + Assert.Contains(cultures, c => c.TwoLetterISOLanguageName == "es"); + } + + [Fact] + public async Task CultureChanged_EmitsMultipleNotifications() + { + // Arrange + var notifications = new List(); + using var subscription = _service.CultureChanged.Subscribe(c => notifications.Add(c)); + + // Act + await _service.SetCulture("de"); + await _service.SetCulture("fr"); + await _service.SetCulture("en"); + + // Assert + Assert.Equal(3, notifications.Count); + Assert.Equal("de", notifications[0].TwoLetterISOLanguageName); + Assert.Equal("fr", notifications[1].TwoLetterISOLanguageName); + Assert.Equal("en", notifications[2].TwoLetterISOLanguageName); + } + + [Fact] + public async Task SetCulture_ThreadSafe_HandlesMultipleConcurrentCalls() + { + // Arrange + var cultures = new[] { "en", "de", "fr" }; + var tasks = new List(); + + // Act + for (int i = 0; i < 10; i++) + { + var culture = cultures[i % cultures.Length]; + tasks.Add(_service.SetCulture(culture)); + } + + await Task.WhenAll(tasks); + + // Assert + Assert.NotNull(_service.CurrentCulture); + Assert.Contains(_service.CurrentCulture.TwoLetterISOLanguageName, cultures); + } + + [Fact] + public void Dispose_CompletesObservable() + { + // Arrange + bool completed = false; + using var subscription = _service.CultureChanged.Subscribe( + _ => { }, + () => completed = true); + + // Act + _service.Dispose(); + + // Assert + Assert.True(completed); + } + + [Fact] + public void GetString_WithThrowOnMissingTranslationEnabled_ThrowsException() + { + // Arrange + var options = new LocalizationOptions + { + ThrowOnMissingTranslation = true + }; + + using var service = new LocalizationService(_mockLanguageProvider.Object, _mockLogger.Object, options); + var key = "NonExistentKey"; + + // Act & Assert + Assert.Throws(() => service.GetString(key)); + } + + [Fact] + public void GetString_WithLogMissingTranslationsDisabled_ReturnsCleanKey() + { + // Arrange + var options = new LocalizationOptions + { + LogMissingTranslations = false, + ThrowOnMissingTranslation = false + }; + + using var service = new LocalizationService(_mockLanguageProvider.Object, _mockLogger.Object, options); + var key = "NonExistentKey"; + + // Act + var result = service.GetString(key); + + // Assert + Assert.Equal(key, result); + Assert.DoesNotContain("[", result); + Assert.DoesNotContain("]", result); + } +} \ No newline at end of file From dc1ea878d71e5d715be45ef9a75da222bee09c0d Mon Sep 17 00:00:00 2001 From: Omar Aglan Date: Thu, 6 Nov 2025 04:23:54 +0200 Subject: [PATCH 06/20] Add localization resource files and documentation Introduced new .resx resource files for error messages, validation, confirmations, success messages, tooltips, and UI labels to support application localization. Also added documentation describing the localization system design. --- .../Resources/Strings/Errors.Operations.resx | 211 ++ .../Resources/Strings/Errors.Validation.resx | 133 ++ .../Strings/Messages.Confirmations.resx | 167 ++ .../Resources/Strings/Messages.Success.resx | 167 ++ .../Resources/Strings/Tooltips.resx | 247 +++ .../Resources/Strings/UI.Common.resx | 183 ++ .../Resources/Strings/UI.GameProfiles.resx | 137 ++ .../Resources/Strings/UI.Navigation.resx | 91 + .../Resources/Strings/UI.Settings.resx | 199 ++ .../Resources/Strings/UI.Updates.resx | 155 ++ .../localization-system-design.md | 1814 +++++++++++++++++ 11 files changed, 3504 insertions(+) create mode 100644 GenHub/GenHub.Core/Resources/Strings/Errors.Operations.resx create mode 100644 GenHub/GenHub.Core/Resources/Strings/Errors.Validation.resx create mode 100644 GenHub/GenHub.Core/Resources/Strings/Messages.Confirmations.resx create mode 100644 GenHub/GenHub.Core/Resources/Strings/Messages.Success.resx create mode 100644 GenHub/GenHub.Core/Resources/Strings/Tooltips.resx create mode 100644 GenHub/GenHub.Core/Resources/Strings/UI.Common.resx create mode 100644 GenHub/GenHub.Core/Resources/Strings/UI.GameProfiles.resx create mode 100644 GenHub/GenHub.Core/Resources/Strings/UI.Navigation.resx create mode 100644 GenHub/GenHub.Core/Resources/Strings/UI.Settings.resx create mode 100644 GenHub/GenHub.Core/Resources/Strings/UI.Updates.resx create mode 100644 docs/architecture/localization-system-design.md diff --git a/GenHub/GenHub.Core/Resources/Strings/Errors.Operations.resx b/GenHub/GenHub.Core/Resources/Strings/Errors.Operations.resx new file mode 100644 index 000000000..bab94ed5e --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/Errors.Operations.resx @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Operation failed + Generic operation failure message + + + An unexpected error occurred + Generic unexpected error message + + + An unexpected error occurred: {0} + Unexpected error with details. {0} is the error message + + + + + Failed to load settings + Error message when settings fail to load + + + Failed to save settings + Error message when settings fail to save + + + Failed to reset settings to defaults + Error message when settings reset fails + + + Failed to update selected tab setting + Error message when updating selected tab fails + + + + + Error occurred while browsing for game path + Error message when browsing for game path fails + + + Error occurred while browsing for settings file path + Error message when browsing for settings file path fails + + + Error occurred while browsing for CAS root path + Error message when browsing for CAS root path fails + + + Failed to get memory usage + Error message when retrieving memory usage fails + + + + + Error initializing profiles + Error message when profile initialization fails + + + Error scanning for games + Error message when game scanning fails + + + Error occurred during automatic profile creation + Error message when automatic profile creation fails + + + Error creating profile for installation {0} + Error message when creating a specific profile fails. {0} is installation ID + + + + + Failed to load initial settings + Error message when loading initial settings fails + + + Could not find main window to show update dialog + Error message when main window cannot be found + + + Failed to show update notification window + Error message when showing update window fails + + + + + Update check failed + Error message when update check fails + + + Failed to install update + Error message when update installation fails + + + Failed to open browser for release notes. + Error message when opening browser for release notes fails + + + + + An unexpected error occurred: {0} + Error message when content search fails. {0} is the error message + + + + + Error retrieving resource string for key: {0} + Error message when retrieving a resource string fails. {0} is the key + + + Format error for key '{0}' with {1} arguments + Error message for format errors. {0} is key, {1} is argument count + + + Error formatting resource string for key: {0} + Error message when formatting a resource string fails. {0} is the key + + + Error setting culture to: {0} + Error message when setting culture fails. {0} is the culture name + + + Missing translation for key '{0}' in culture '{1}' + Error/warning for missing translations. {0} is key, {1} is culture + + + Missing translation for key '{0}' in culture '{1}' + Exception message for missing translations. {0} is key, {1} is culture + + + + + Error discovering available languages + Error message when discovering available languages fails + + + Error creating ResourceManager for base name: {0} + Error message when creating ResourceManager fails. {0} is the base name + + + Error validating culture: {0} + Error message when culture validation fails. {0} is the culture name + + + Unexpected error validating culture: {0} + Unexpected error during culture validation. {0} is the culture name + + + Error during satellite assembly discovery + Error message when satellite assembly discovery fails + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/Errors.Validation.resx b/GenHub/GenHub.Core/Resources/Strings/Errors.Validation.resx new file mode 100644 index 000000000..c45a27b07 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/Errors.Validation.resx @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Invalid MaxConcurrentDownloads value: {0}. Resetting to 3. + Validation error for max concurrent downloads. {0} is the invalid value + + + Invalid DownloadBufferSizeKB value: {0}. Resetting to 80KB. + Validation error for download buffer size. {0} is the invalid value + + + Preferred game install path does not exist: {0} + Validation warning when specified path doesn't exist. {0} is the path + + + Settings file directory does not exist: {0} + Validation warning when settings directory doesn't exist. {0} is the directory + + + + + Concurrent downloads must be between {0} and {1} + Validation error for concurrent downloads range. {0} is min, {1} is max + + + Download timeout must be between {0} and {1} seconds + Validation error for download timeout range. {0} is min seconds, {1} is max seconds + + + Buffer size must be between {0} and {1} KB + Validation error for buffer size range. {0} is min KB, {1} is max KB + + + + + This field is required + Generic required field validation message + + + Invalid format + Generic invalid format validation message + + + Invalid path + Generic invalid path validation message + + + Invalid URL + Generic invalid URL validation message + + + Value is too low (minimum: {0}) + Validation error when value is below minimum. {0} is the minimum value + + + Value is too high (maximum: {0}) + Validation error when value is above maximum. {0} is the maximum value + + + + + Invalid culture name: {0} + Error when culture name is invalid. {0} is the culture name + + + Culture '{0}' is not available. Falling back to '{1}' + Warning when culture is not available. {0} is requested culture, {1} is fallback + + + Culture '{0}' not found. Using invariant culture + Warning when culture is not found. {0} is the culture name + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/Messages.Confirmations.resx b/GenHub/GenHub.Core/Resources/Strings/Messages.Confirmations.resx new file mode 100644 index 000000000..3f75e8328 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/Messages.Confirmations.resx @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Are you sure you want to delete this profile? + Confirmation message for deleting a profile + + + Are you sure you want to delete the profile '{0}'? + Confirmation message for deleting a specific profile. {0} is profile name + + + Are you sure you want to delete this file? + Confirmation message for deleting a file + + + Are you sure you want to delete {0} files? + Confirmation message for deleting multiple files. {0} is file count + + + Are you sure you want to delete this folder and all its contents? + Confirmation message for deleting a folder + + + + + Are you sure you want to reset all settings to defaults? + Confirmation message for resetting settings + + + Are you sure you want to reset this profile to default settings? + Confirmation message for resetting a profile + + + Are you sure you want to clear the cache? This cannot be undone. + Confirmation message for clearing cache + + + + + A file with this name already exists. Do you want to overwrite it? + Confirmation message for overwriting a file + + + A profile with this name already exists. Do you want to overwrite it? + Confirmation message for overwriting a profile + + + Replace existing {0}? + Confirmation message for replacing existing item. {0} is item type + + + + + Are you sure you want to exit the application? + Confirmation message for exiting the application + + + You have unsaved changes. Do you want to save before closing? + Confirmation message when closing with unsaved changes + + + Are you sure you want to cancel this operation? + Confirmation message for canceling an operation + + + Are you sure you want to stop this download? + Confirmation message for stopping a download + + + + + Do you want to apply these changes? + Confirmation message for applying changes + + + Are you sure you want to discard all changes? + Confirmation message for discarding changes + + + Do you want to proceed with this action? + Generic confirmation message for proceeding with an action + + + There are warnings. Do you want to continue anyway? + Confirmation message for continuing despite warnings + + + + + An update is available. Do you want to install it now? + Confirmation message for installing an update + + + The application needs to restart to apply the update. Restart now? + Confirmation message for restarting to apply update + + + + + Are you sure you want to import this data? Existing data may be overwritten. + Confirmation message for importing data + + + Do you want to export this data? + Confirmation message for exporting data + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/Messages.Success.resx b/GenHub/GenHub.Core/Resources/Strings/Messages.Success.resx new file mode 100644 index 000000000..79de90664 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/Messages.Success.resx @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Settings saved successfully + Success message when settings are saved + + + Settings reset to defaults + Success message when settings are reset to defaults + + + Settings loaded successfully + Success message when settings are loaded + + + + + Profile created successfully + Success message when a profile is created + + + Profile updated successfully + Success message when a profile is updated + + + Profile deleted successfully + Success message when a profile is deleted + + + Profiles saved successfully + Success message when profiles are saved + + + + + MainViewModel initialized + Log message when MainViewModel is initialized + + + Game profile launcher initialized + Log message when game profile launcher is initialized + + + UpdateNotificationViewModel initialized + Log message when UpdateNotificationViewModel is initialized + + + + + LocalizationService initialized with culture: {0} + Log message when LocalizationService is initialized. {0} is culture name + + + Culture changed to: {0} + Log message when culture is changed. {0} is the new culture name + + + Available cultures refreshed. Found {0} cultures + Log message when available cultures are refreshed. {0} is culture count + + + Added default culture: {0} + Log message when default culture is added. {0} is culture name + + + Discovered {0} available languages: {1} + Log message for discovered languages. {0} is count, {1} is comma-separated list + + + Found satellite assembly for culture: {0} + Log message when satellite assembly is found. {0} is culture name + + + Created ResourceManager for base name: {0} + Log message when ResourceManager is created. {0} is base name + + + + + Update check completed. Update available: {0} + Log message when update check completes. {0} is true/false + + + + + Game scan completed successfully. Found {0} installations + Success message when game scan completes. {0} is installation count + + + + + Operation completed successfully + Generic operation completion message + + + Data saved successfully + Generic data save success message + + + Import completed successfully + Success message for import operations + + + Export completed successfully + Success message for export operations + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/Tooltips.resx b/GenHub/GenHub.Core/Resources/Strings/Tooltips.resx new file mode 100644 index 000000000..7ad7f5b65 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/Tooltips.resx @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Save changes + Tooltip for save button + + + Cancel and discard changes + Tooltip for cancel button + + + Refresh data + Tooltip for refresh button + + + Delete selected item + Tooltip for delete button + + + Edit selected item + Tooltip for edit button + + + Add new item + Tooltip for add button + + + Browse for file or folder + Tooltip for browse button + + + Reset to default values + Tooltip for reset button + + + + + Choose between dark and light theme + Tooltip for theme setting + + + Default workspace directory for game profiles + Tooltip for workspace path setting + + + Maximum number of simultaneous downloads (1-10) + Tooltip for max concurrent downloads setting + + + Buffer size for downloads in kilobytes + Tooltip for download buffer size setting + + + Timeout duration for downloads in seconds + Tooltip for download timeout setting + + + Automatically check for application updates when starting + Tooltip for auto check updates setting + + + Allow downloads to continue in the background + Tooltip for allow background downloads setting + + + Enable detailed logging for troubleshooting + Tooltip for enable detailed logging setting + + + Default strategy for creating game workspaces + Tooltip for default workspace strategy setting + + + Root directory for Content-Addressable Storage + Tooltip for CAS root path setting + + + Automatically clean up unused cache files + Tooltip for enable automatic GC setting + + + Maximum cache size in gigabytes + Tooltip for max cache size setting + + + Verify file integrity during cache operations + Tooltip for verify integrity setting + + + + + Create a new game profile + Tooltip for create new profile button + + + Launch game with this profile + Tooltip for launch profile button + + + Edit profile settings + Tooltip for edit profile button + + + Delete this profile + Tooltip for delete profile button + + + Create desktop shortcut for this profile + Tooltip for create shortcut button + + + Scan system for installed games + Tooltip for scan for games button + + + + + Check for application updates + Tooltip for check for updates button + + + View release notes in browser + Tooltip for view release notes button + + + Download and install the update + Tooltip for install update button + + + Dismiss update notification + Tooltip for dismiss update button + + + + + Manage and launch game profiles + Tooltip for game profiles navigation tab + + + View and manage downloads + Tooltip for downloads navigation tab + + + Configure application settings + Tooltip for settings navigation tab + + + Browse and search for content + Tooltip for content browser navigation tab + + + + + Search for content + Tooltip for content search button + + + Filter search results + Tooltip for content filter options + + + Sort search results + Tooltip for content sort options + + + + + Show help information + Tooltip for help icon + + + Show additional information + Tooltip for info icon + + + Warning - attention required + Tooltip for warning icon + + + Error - action failed + Tooltip for error icon + + + Success - action completed + Tooltip for success icon + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/UI.Common.resx b/GenHub/GenHub.Core/Resources/Strings/UI.Common.resx new file mode 100644 index 000000000..dd3e90752 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Common.resx @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Save + Generic save button text + + + Cancel + Generic cancel button text + + + OK + Generic OK button text + + + Apply + Generic apply button text + + + Close + Generic close button text + + + Back + Generic back navigation button + + + Next + Generic next navigation button + + + Browse + Generic browse button text + + + Delete + Generic delete button text + + + Edit + Generic edit button text + + + Add + Generic add button text + + + Remove + Generic remove button text + + + Refresh + Generic refresh button text + + + Search + Generic search button text + + + + + Loading... + Generic loading status message + + + Please wait... + Generic please wait message + + + Success + Generic success status + + + Failed + Generic failed status + + + Error + Generic error status + + + Warning + Generic warning status + + + Info + Generic info status + + + + + Copy + Copy action text + + + Paste + Paste action text + + + Cut + Cut action text + + + Select + Select action text + + + Clear + Clear action text + + + Reset + Reset action text + + + Import + Import action text + + + Export + Export action text + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/UI.GameProfiles.resx b/GenHub/GenHub.Core/Resources/Strings/UI.GameProfiles.resx new file mode 100644 index 000000000..6c8ef7c9a --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.GameProfiles.resx @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Loading profiles... + Status message shown when loading game profiles + + + Profiles loaded + Status message shown when profiles are successfully loaded + + + Error loading profiles + Status message shown when profile loading fails + + + + + Scanning for games... + Status message shown when scanning for game installations + + + Scan complete. Found {0} game installations + Status message shown when scan completes. {0} is the count of installations found + + + Scan failed: {0} + Status message shown when scan fails. {0} is the error message + + + Error during scan + Status message shown when an unexpected error occurs during scan + + + + + Game installation service not available + Error message shown when required service is not available + + + Logger not available - scan may proceed without detailed logging + Warning message shown when logger is not available + + + + + {0} Profile + Auto-generated profile name. {0} is the installation type + + + Auto-created profile for {0} installation at {1} + Auto-generated profile description. {0} is installation type, {1} is path + + + Found {0} game installations, creating profiles + Status message during profile creation. {0} is installation count + + + Created profile '{0}' for installation {1} + Log message for successful profile creation. {0} is profile name, {1} is installation ID + + + Failed to create profile for installation {0}: {1} + Log message for failed profile creation. {0} is installation ID, {1} is error message + + + Profile creation complete: {0} created, {1} failed + Summary message after profile creation. {0} is success count, {1} is failure count + + + No game installations found + Message shown when no game installations are detected + + + Game installation scan failed: {0} + Warning message when installation scan fails. {0} is error details + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/UI.Navigation.resx b/GenHub/GenHub.Core/Resources/Strings/UI.Navigation.resx new file mode 100644 index 000000000..6d8d7040f --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Navigation.resx @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Game Profiles + Navigation tab name for game profiles section + + + Downloads + Navigation tab name for downloads section + + + Settings + Navigation tab name for settings section + + + Content + Navigation tab name for content browser section + + + About + Navigation tab name for about section + + + Updates + Navigation tab name for updates section + + + Home + Navigation tab name for home section + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/UI.Settings.resx b/GenHub/GenHub.Core/Resources/Strings/UI.Settings.resx new file mode 100644 index 000000000..73998edae --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Settings.resx @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Save Settings + Button text for saving settings + + + Saving... + Button text shown while saving is in progress + + + + + Theme + Label for theme selection setting + + + Dark + Dark theme option + + + Light + Light theme option + + + + + Workspace Path + Label for workspace path setting + + + Settings File Path + Label for settings file path setting + + + Cache Path + Label for cache path setting + + + Content Storage Path + Label for content storage path setting + + + CAS Root Path + Label for Content-Addressable Storage root path setting + + + + + Select Game Installation Directory + Title for folder picker dialog when selecting game install directory + + + Select Settings File Location + Title for file picker dialog when selecting settings file location + + + Select Content-Addressable Storage Root Directory + Title for folder picker dialog when selecting CAS root directory + + + + + Max Concurrent Downloads + Label for maximum concurrent downloads setting + + + Download Buffer Size (KB) + Label for download buffer size setting + + + Download Timeout (Seconds) + Label for download timeout setting + + + Download User Agent + Label for download user agent setting + + + Allow Background Downloads + Label for allow background downloads checkbox + + + + + Auto Check for Updates on Startup + Label for auto check for updates checkbox + + + Enable Detailed Logging + Label for enable detailed logging checkbox + + + Default Workspace Strategy + Label for default workspace strategy setting + + + + + Enable Automatic Garbage Collection + Label for enable automatic garbage collection checkbox + + + Max Cache Size (GB) + Label for maximum cache size setting + + + Max Concurrent Operations + Label for maximum concurrent CAS operations + + + Verify Integrity + Label for verify integrity checkbox + + + Garbage Collection Grace Period (Days) + Label for garbage collection grace period setting + + + Auto GC Interval (Days) + Label for automatic garbage collection interval setting + + + + + Content Directories + Label for content directories setting + + + GitHub Discovery Repositories + Label for GitHub discovery repositories setting + + + + + Current Memory Usage (MB) + Label for current memory usage display + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/UI.Updates.resx b/GenHub/GenHub.Core/Resources/Strings/UI.Updates.resx new file mode 100644 index 000000000..19faec838 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Updates.resx @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + Ready to check for updates + Initial status message before checking for updates + + + Checking for updates... + Status message while checking for updates + + + Update available: {0} + Status message when update is available. {0} is the version number + + + No updates available + Status message when no updates are available + + + Update check failed + Status message when update check fails + + + + + Preparing to download update... + Status message when preparing to download update + + + Preparing download... + Progress message when preparing download + + + Update prepared successfully! The application will restart automatically. + Status message when update is successfully prepared + + + Update prepared successfully! The application will restart automatically. + Progress message when update is successfully prepared + + + Update installation failed. + Status message when update installation fails + + + Installation failed + Progress message when installation fails + + + Update failed + Status message when update process fails + + + Ready + Initial progress message + + + + + Install Update + Button text for installing update + + + Installing... + Button text shown while installing + + + + + No compatible update package found for your platform. + Error message when no compatible update package is found + + + Failed to check for updates: {0} + Error message when update check fails. {0} is the error details + + + Update failed: {0} + Error message when update fails. {0} is the error details + + + + + Current Version + Label for current application version + + + Latest Version + Label for latest available version + + + v0.0.0 + Default version display when no version is available + + \ No newline at end of file diff --git a/docs/architecture/localization-system-design.md b/docs/architecture/localization-system-design.md new file mode 100644 index 000000000..5fcbc15ca --- /dev/null +++ b/docs/architecture/localization-system-design.md @@ -0,0 +1,1814 @@ +# GenHub Localization System Architecture Design + +## Document Information + +**Version:** 2.0 (Revised for .resx) +**Date:** 2025-10-09 +**Status:** Architecture Design Phase +**Author:** Architecture Team + +--- + +## Executive Summary + +### Recommendation: .NET Resource Files (.resx) with Custom Service Layer + +After consideration of GenHub's requirements and alignment with Avalonia's official recommendations, **I recommend using .NET Resource Files (.resx)** with a custom service layer for runtime language switching. This approach provides: + +- **Official Avalonia Support**: Follows Avalonia's documented localization approach +- **Strong Tooling**: Visual Studio and Rider provide excellent .resx editing support +- **Type Safety**: Strongly-typed resource classes with IntelliSense +- **Native .NET Integration**: Built-in ResourceManager framework +- **Design-Time XAML Support**: Resource keys available in XAML with IntelliSense +- **Professional Workflow**: Compatible with professional translation tools (SDL Trados, memoQ) + +### Key Architectural Decisions + +1. **Technology**: .NET Resource Files (.resx) with satellite assemblies +2. **Service Pattern**: `ILocalizationService` wrapper around `ResourceManager` for enhanced functionality +3. **Resource Organization**: Namespace-based .resx files (e.g., `UI.Common.resx`, `Errors.Validation.resx`) +4. **Fallback Strategy**: Native .NET fallback (Requested → Neutral → Invariant) +5. **Language Discovery**: Assembly scanning for satellite assemblies +6. **Runtime Switching**: Custom ResourceManager with culture switching and reactive notifications +7. **Performance**: Satellite assemblies with lazy loading +8. **Translator Experience**: ResXManager tool for community contributors + +### Addressing .resx Concerns + +While .resx files have some challenges, we can mitigate them: + +| Concern | Mitigation Strategy | +|---------|---------------------| +| **Poor translator UX** | Provide ResXManager tool, detailed contribution guide | +| **Runtime language switching complexity** | Custom service layer with `CultureInfo` management | +| **XML verbosity in version control** | Git attributes for better diffs, focus on satellite assemblies in PRs | +| **Linux tooling limitations** | ResXManager is cross-platform, provide CLI tools | +| **Community contribution friction** | Streamlined PR workflow, automated validation, clear documentation | + +### Why .resx is the Right Choice + +| Criteria | .resx Advantage | +|----------|-----------------| +| **Avalonia alignment** | ✅ Official recommendation | +| **Type safety** | ✅ Strongly-typed resource classes | +| **Design-time support** | ✅ Native XAML IntelliSense | +| **Professional translation** | ✅ Industry-standard tools support .resx | +| **Framework integration** | ✅ Built into .NET framework | +| **Parameter substitution** | ✅ Native `string.Format` support | +| **Satellite assemblies** | ✅ Clean separation, easy deployment | +| **Cultural formatting** | ✅ Automatic date/number/currency formatting | + +--- + +## Table of Contents + +1. [Technology Evaluation](#1-technology-evaluation) +2. [System Architecture Design](#2-system-architecture-design) +3. [Avalonia Integration Strategy](#3-avalonia-integration-strategy) +4. [Developer Experience](#4-developer-experience) +5. [Translator Experience](#5-translator-experience) +6. [Implementation Phases](#6-implementation-phases) +7. [Technical Specifications](#7-technical-specifications) +8. [Testing Strategy](#8-testing-strategy) +9. [Open Questions and Future Considerations](#9-open-questions-and-future-considerations) + +--- + +## 1. Technology Evaluation + +### 1.1 Selected Approach: .NET Resource Files (.resx) + +**Overview**: Traditional .NET localization using ResX files compiled into satellite assemblies. + +**Strengths**: +- ✅ **Officially recommended** by Avalonia documentation +- ✅ **Native .NET framework** support with ResourceManager +- ✅ **Strong tooling** in Visual Studio, Rider, and ResXManager +- ✅ **Design-time XAML IntelliSense** for resource keys +- ✅ **Type-safe generated classes** (e.g., `Resources.UI_Common_Save`) +- ✅ **Built-in parameter substitution** via `string.Format` +- ✅ **Satellite assemblies** for clean deployment and optional language packs +- ✅ **Professional translation workflows** (XLIFF export/import) +- ✅ **Cultural formatting** (dates, numbers, currency) automatic + +**Challenges and Mitigations**: + +| Challenge | Mitigation | +|-----------|------------| +| XML-based format for translators | Provide ResXManager GUI tool (cross-platform) | +| Runtime language switching complexity | Custom `LocalizationService` wrapping `ResourceManager` | +| Version control noise | Git attributes for .resx files, focus PRs on satellite assemblies | +| Community contribution barrier | Detailed guide, ResXManager, validation tools | +| Linux tooling | ResXManager runs on Linux via .NET, provide CLI alternative | + +**Decision**: The benefits of .resx (native support, tooling, type safety) outweigh the challenges, especially with proper mitigation strategies. + +### 1.2 Alternative: JSON-Based Localization + +**Why Not JSON**: While JSON is more accessible to non-technical users, .resx provides: +- Native Avalonia support (less custom code) +- Better tooling ecosystem +- Industry-standard translation workflows +- Type-safe resource access +- Compiled resources (slightly better performance) + +**Verdict**: .resx is the better fit for a production-quality application, despite JSON's simplicity advantage. + +### 1.3 Alternative: XAML Resource Dictionaries + +**Why Not**: XAML dictionaries don't scale well, lack parameter substitution, and have poor validation. + +**Verdict**: Not suitable for comprehensive localization. + +--- + +## 2. System Architecture Design + +### 2.1 Service Layer Architecture + +#### 2.1.1 Core Interfaces + +```csharp +namespace GenHub.Core.Interfaces.Localization; + +/// +/// Provides localization services for retrieving translated strings with runtime language switching. +/// +public interface ILocalizationService +{ + /// + /// Gets the currently active culture. + /// + CultureInfo CurrentCulture { get; } + + /// + /// Gets an observable that emits whenever the language/culture changes. + /// Subscribe to this for reactive UI updates. + /// + IObservable CultureChanged { get; } + + /// + /// Gets all available cultures (languages) in the application. + /// + IReadOnlyList AvailableLanguages { get; } + + /// + /// Changes the active culture/language. + /// + /// The culture to activate. + /// Operation result indicating success or failure. + OperationResult SetCulture(CultureInfo culture); + + /// + /// Changes the active culture by language code (e.g., "en", "de"). + /// + /// The language code. + /// Operation result indicating success or failure. + OperationResult SetLanguage(string languageCode); + + /// + /// Gets a localized string from the specified resource set. + /// + /// The resource set name (e.g., "UI.Common"). + /// The resource key (e.g., "Save"). + /// The localized string, or the key if not found. + string GetString(string resourceSet, string key); + + /// + /// Gets a localized string with parameter substitution. + /// + /// The resource set name. + /// The resource key. + /// Arguments for string.Format. + /// The formatted localized string. + string GetString(string resourceSet, string key, params object[] args); + + /// + /// Tries to get a localized string, returning success status. + /// + bool TryGetString(string resourceSet, string key, out string value); +} + +/// +/// Represents information about an available language. +/// +public class LanguageInfo +{ + /// Gets the culture info. + public required CultureInfo Culture { get; init; } + + /// Gets the language code (e.g., "en", "de"). + public string Code => Culture.TwoLetterISOLanguageName; + + /// Gets the native language name (e.g., "English", "Deutsch"). + public string NativeName => Culture.NativeName; + + /// Gets the English language name (e.g., "English", "German"). + public string EnglishName => Culture.EnglishName; + + /// Gets whether this is the default fallback language. + public bool IsDefault { get; init; } + + /// Gets whether a satellite assembly exists for this culture. + public bool HasSatelliteAssembly { get; init; } +} +``` + +#### 2.1.2 Resource Manager Abstraction + +```csharp +namespace GenHub.Core.Interfaces.Localization; + +/// +/// Manages resource managers for different resource sets. +/// +public interface IResourceManagerProvider +{ + /// + /// Gets a ResourceManager for the specified resource set. + /// + /// The resource set name (e.g., "UI.Common"). + /// The ResourceManager instance. + ResourceManager GetResourceManager(string resourceSet); + + /// + /// Discovers all available satellite assembly cultures. + /// + /// List of available cultures. + IReadOnlyList DiscoverAvailableCultures(); + + /// + /// Refreshes all resource managers (useful after culture change). + /// + void RefreshResourceManagers(); +} +``` + +#### 2.1.3 Dependency Injection Integration + +**Service Lifetimes**: +- `ILocalizationService`: **Singleton** - Single instance for app-wide culture management +- `IResourceManagerProvider`: **Singleton** - Caches ResourceManager instances +- Resource classes (e.g., `Resources`): **Static** - Auto-generated by .NET + +**Registration Pattern**: +```csharp +public static class LocalizationModule +{ + public static IServiceCollection AddLocalizationServices( + this IServiceCollection services, + IConfigurationProviderService configProvider) + { + // Register resource manager provider + services.AddSingleton(); + + // Register localization service + services.AddSingleton(provider => + { + var resourceProvider = provider.GetRequiredService(); + var logger = provider.GetRequiredService>(); + var userSettings = provider.GetRequiredService(); + + // Get default culture from user settings or app config + var defaultCulture = GetCultureFromSettings(userSettings, configProvider); + + return new LocalizationService( + resourceProvider, + defaultCulture, + logger); + }); + + return services; + } + + private static CultureInfo GetCultureFromSettings( + IUserSettingsService userSettings, + IConfigurationProviderService configProvider) + { + var languageCode = userSettings.Get().PreferredLanguage + ?? configProvider.GetDefaultLanguage(); + + try + { + return CultureInfo.GetCultureInfo(languageCode); + } + catch + { + return CultureInfo.GetCultureInfo("en-US"); + } + } +} +``` + +### 2.2 Resource Organization + +#### 2.2.1 Project Structure + +``` +GenHub/ +├── GenHub.Core/ +│ ├── Resources/ +│ │ ├── Strings/ # All .resx files +│ │ │ ├── UI.Common.resx # English (neutral/default) +│ │ │ ├── UI.Common.de.resx # German +│ │ │ ├── UI.Common.fr.resx # French +│ │ │ ├── UI.Common.es.resx # Spanish +│ │ │ ├── UI.GameProfiles.resx # English +│ │ │ ├── UI.GameProfiles.de.resx # German +│ │ │ ├── Errors.Validation.resx # English +│ │ │ ├── Errors.Validation.de.resx +│ │ │ └── ... +│ │ └── Strings.Designer.cs # Auto-generated (if using custom) +│ └── Localization/ +│ └── LocalizationKeys.cs # Helper class (optional) +``` + +**Build Configuration** (`GenHub.Core.csproj`): +```xml + + + net8.0 + enable + + en;de;fr;es + + + + + + ResXFileCodeGenerator + GenHub.Core.Resources + + + +``` + +#### 2.2.2 Resource File Naming Convention + +**Pattern**: `..resx` + +**Examples**: +- `UI.Common.resx` → Common UI strings (Save, Cancel, etc.) +- `UI.GameProfiles.resx` → Game Profiles view strings +- `UI.Settings.resx` → Settings view strings +- `UI.Navigation.resx` → Navigation tab strings +- `Errors.Validation.resx` → Validation error messages +- `Errors.Launch.resx` → Game launch error messages +- `Messages.Success.resx` → Success notification messages +- `Tooltips.resx` → Tooltip texts + +**Culture-Specific Naming**: +- `UI.Common.resx` → English (neutral culture, fallback) +- `UI.Common.de.resx` → German +- `UI.Common.fr.resx` → French +- `UI.Common.es.resx` → Spanish + +#### 2.2.3 Resource File Content Structure + +**UI.Common.resx** (English - Neutral): +```xml + + + + Save + Button text for save action + + + Cancel + Button text for cancel action + + + Delete + Button text for delete action + + + Edit + Button text for edit action + + + Create + Button text for create action + + + Browse... + Button text for file browser + + + Refresh + Button text for refresh action + + +``` + +**UI.GameProfiles.resx** (English): +```xml + + + + Game Profiles + Title for Game Profiles view + + + No game profiles found. Create one to get started! + Message shown when no profiles exist + + + Create Profile + Button text to create a new profile + + + Launch Game + Button text to launch a game + + + Profile Name + Label for profile name field + + +``` + +**Errors.Launch.resx** (English with parameters): +```xml + + + + Game profile '{0}' not found + Error when profile doesn't exist. Parameter: {0} = profile name + + + Failed to prepare workspace: {0} + Error during workspace preparation. Parameter: {0} = error details + + + Failed to start game process + Error when game process won't start + + +``` + +#### 2.2.4 Auto-Generated Resource Classes + +**.NET automatically generates** strongly-typed resource classes: + +**Conceptual output** (actual is in satellite assemblies): +```csharp +namespace GenHub.Core.Resources +{ + internal class UI_Common + { + private static ResourceManager resourceMan; + private static CultureInfo resourceCulture; + + internal static ResourceManager ResourceManager + { + get + { + if (resourceMan == null) + { + resourceMan = new ResourceManager( + "GenHub.Core.Resources.Strings.UI.Common", + typeof(UI_Common).Assembly); + } + return resourceMan; + } + } + + internal static string Save + { + get { return ResourceManager.GetString("Save", resourceCulture); } + } + + internal static string Cancel + { + get { return ResourceManager.GetString("Cancel", resourceCulture); } + } + + // ... other properties + } +} +``` + +### 2.3 Fallback Mechanism + +#### 2.3.1 Native .NET Resource Fallback + +**.NET provides built-in fallback**: + +1. **Requested Culture** (e.g., `de-DE`) +2. **Neutral Culture** (e.g., `de`) +3. **Neutral Resource** (e.g., English from `UI.Common.resx`) + +**Example**: +- User selects German (`de-DE`) +- ResourceManager looks for `UI.Common.de-DE.resx` (doesn't exist) +- Falls back to `UI.Common.de.resx` (exists, uses this) +- If `de.resx` missing a key, falls back to `UI.Common.resx` (English) + +**No custom fallback logic needed** - .NET handles it automatically! + +#### 2.3.2 Missing Translation Handling + +**Development Mode**: +```csharp +public class LocalizationService : ILocalizationService +{ + public string GetString(string resourceSet, string key) + { + var resourceManager = _resourceProvider.GetResourceManager(resourceSet); + + try + { + var value = resourceManager.GetString(key, CultureInfo.CurrentUICulture); + + if (string.IsNullOrEmpty(value)) + { + _logger.LogWarning( + "Missing resource: {ResourceSet}.{Key} in culture {Culture}", + resourceSet, key, CultureInfo.CurrentUICulture.Name); + + return $"[{resourceSet}.{key}]"; // Visual indicator + } + + return value; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error getting resource: {ResourceSet}.{Key}", + resourceSet, key); + + return $"[{resourceSet}.{key}]"; + } + } +} +``` + +### 2.4 Dynamic Language Discovery + +#### 2.4.1 Satellite Assembly Discovery + +**Automatic Discovery** via assembly scanning: + +```csharp +public class ResourceManagerProvider : IResourceManagerProvider +{ + private readonly Dictionary _resourceManagers = new(); + private IReadOnlyList? _availableCultures; + + public IReadOnlyList DiscoverAvailableCultures() + { + if (_availableCultures != null) + return _availableCultures; + + var cultures = new List(); + var assembly = typeof(ResourceManagerProvider).Assembly; + + // Get all satellite assemblies + var satelliteCultures = CultureInfo.GetCultures(CultureTypes.AllCultures) + .Where(culture => + { + try + { + // Check if satellite assembly exists + var satelliteAssembly = assembly.GetSatelliteAssembly(culture); + return satelliteAssembly != null; + } + catch + { + return false; + } + }) + .ToList(); + + // Add neutral/invariant culture (English) + cultures.Add(CultureInfo.InvariantCulture); + cultures.Add(CultureInfo.GetCultureInfo("en-US")); + + // Add discovered satellite cultures + cultures.AddRange(satelliteCultures); + + _availableCultures = cultures + .DistinctBy(c => c.TwoLetterISOLanguageName) + .OrderBy(c => c.EnglishName) + .ToList(); + + return _availableCultures; + } + + public ResourceManager GetResourceManager(string resourceSet) + { + if (_resourceManagers.TryGetValue(resourceSet, out var manager)) + return manager; + + // Create ResourceManager for this set + var baseName = $"GenHub.Core.Resources.Strings.{resourceSet}"; + var assembly = typeof(ResourceManagerProvider).Assembly; + + manager = new ResourceManager(baseName, assembly); + _resourceManagers[resourceSet] = manager; + + return manager; + } +} +``` + +#### 2.4.2 Language Metadata + +**Create `LanguageInfo` from discovered cultures**: + +```csharp +public IReadOnlyList AvailableLanguages +{ + get + { + var cultures = _resourceProvider.DiscoverAvailableCultures(); + + return cultures.Select(culture => new LanguageInfo + { + Culture = culture, + IsDefault = culture.TwoLetterISOLanguageName == "en", + HasSatelliteAssembly = IsSatelliteAssemblyAvailable(culture) + }).ToList(); + } +} + +private bool IsSatelliteAssemblyAvailable(CultureInfo culture) +{ + try + { + var assembly = typeof(LocalizationService).Assembly; + var satellite = assembly.GetSatelliteAssembly(culture); + return satellite != null; + } + catch + { + return false; + } +} +``` + +### 2.5 Runtime Language Switching + +#### 2.5.1 Culture Switching Strategy + +**Thread-Wide Culture Change**: + +```csharp +public class LocalizationService : ILocalizationService +{ + private readonly Subject _cultureChangedSubject = new(); + private CultureInfo _currentCulture; + + public CultureInfo CurrentCulture => _currentCulture; + + public IObservable CultureChanged => + _cultureChangedSubject.AsObservable(); + + public OperationResult SetCulture(CultureInfo culture) + { + try + { + // Validate culture is available + if (!IsLanguageAvailable(culture)) + { + return OperationResult.CreateFailure( + $"Culture '{culture.Name}' is not available"); + } + + // Set thread cultures + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + Thread.CurrentThread.CurrentCulture = culture; + Thread.CurrentThread.CurrentUICulture = culture; + + _currentCulture = culture; + + // Refresh all resource managers to pick up new culture + _resourceProvider.RefreshResourceManagers(); + + // Notify subscribers on UI thread + RxApp.MainThreadScheduler.Schedule(() => + _cultureChangedSubject.OnNext(culture)); + + _logger.LogInformation( + "Culture changed to: {Culture}", culture.Name); + + return OperationResult.CreateSuccess(true); + } + catch (Exception ex) + { + return OperationResult.CreateFailure( + $"Culture switch failed: {ex.Message}"); + } + } + + public OperationResult SetLanguage(string languageCode) + { + try + { + var culture = CultureInfo.GetCultureInfo(languageCode); + return SetCulture(culture); + } + catch (CultureNotFoundException ex) + { + return OperationResult.CreateFailure( + $"Invalid language code: {languageCode}"); + } + } + + private bool IsLanguageAvailable(CultureInfo culture) + { + return AvailableLanguages.Any(l => + l.Culture.TwoLetterISOLanguageName == culture.TwoLetterISOLanguageName); + } +} +``` + +#### 2.5.2 Resource Manager Refresh + +```csharp +public class ResourceManagerProvider : IResourceManagerProvider +{ + public void RefreshResourceManagers() + { + // ResourceManager automatically uses current thread culture + // No explicit refresh needed, but we clear cache to be safe + foreach (var manager in _resourceManagers.Values) + { + manager.ReleaseAllResources(); + } + } +} +``` + +#### 2.5.3 ViewModel Integration Pattern + +**Reactive Property Updates**: +```csharp +public partial class GameProfilesViewModel : ViewModelBase +{ + private readonly ILocalizationService _localization; + private readonly IResourceManagerProvider _resourceProvider; + + [ObservableProperty] + private string _title; + + [ObservableProperty] + private string _createButtonText; + + [ObservableProperty] + private string _noProfilesMessage; + + public GameProfilesViewModel(ILocalizationService localization) + { + _localization = localization; + + // Initialize with current culture + UpdateLocalizedStrings(); + + // Subscribe to culture changes + _localization.CultureChanged + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(_ => UpdateLocalizedStrings()); + } + + private void UpdateLocalizedStrings() + { + Title = _localization.GetString("UI.GameProfiles", "Title"); + CreateButtonText = _localization.GetString("UI.GameProfiles", "CreateProfile"); + NoProfilesMessage = _localization.GetString("UI.GameProfiles", "NoProfiles"); + } +} +``` + +--- + +## 3. Avalonia Integration Strategy + +### 3.1 XAML Binding with Resource Extensions + +#### 3.1.1 Static Resource Binding (Simple Approach) + +**Using x:Static** with generated resource classes: + +```xaml + + + + + + +