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/Extensions/Localization/LocalizationExtensions.cs b/GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs new file mode 100644 index 000000000..0ea44ff4d --- /dev/null +++ b/GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs @@ -0,0 +1,300 @@ +using System.Globalization; +using GenHub.Core.Interfaces.Localization; +using GenHub.Core.Resources.Strings; + +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(StringResources.UiCommon, 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(StringResources.UiCommon, 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/GenHub.Core.csproj b/GenHub/GenHub.Core/GenHub.Core.csproj index fb5901a66..24dbe6915 100644 --- a/GenHub/GenHub.Core/GenHub.Core.csproj +++ b/GenHub/GenHub.Core/GenHub.Core.csproj @@ -4,6 +4,7 @@ enable enable true + true @@ -12,5 +13,12 @@ + + + + + ResXFileCodeGenerator + GenHub.Core.Resources.Strings + 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..1be36aa65 --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Localization/ILocalizationService.cs @@ -0,0 +1,62 @@ +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 from the specified resource set. + /// + /// The resource set base name (e.g., from StringResources constants). + /// The resource key. + /// The localized string, or a fallback if not found. + string GetString(string resourceSet, string key); + + /// + /// Gets a formatted localized string from the specified resource set. + /// + /// The resource set base name (e.g., from StringResources constants). + /// The resource key. + /// Format arguments. + /// The formatted localized string, or a fallback if not found. + string GetString(string resourceSet, 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 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/Resources/Strings/Errors.Operations.resx b/GenHub/GenHub.Core/Resources/Strings/Errors.Operations.resx new file mode 100644 index 000000000..a60005b86 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/Errors.Operations.resx @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + GameProfileManager service is not available. + Error when GameProfileManager service is unavailable + + + Profile content loader service not available + Error when ProfileContentLoader service is unavailable + + + Game settings service not available + Error when GameSettingsService is unavailable + + + + + Options file directory not found + Error when options file directory cannot be found + + + + + Failed to load tools: {0} + Error when loading tools fails. {0} = error details + + + Error loading tool '{0}': {1} + Error when a specific tool fails to load. {0} = tool name, {1} = error + + \ 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..b433fbab4 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/Errors.Validation.resx @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + Please select a game installation + Validation error when no game installation is selected + + + Please enter a profile name + Validation error when profile name is empty + + + A Game Installation must be enabled for the profile to be launchable. + Validation error when no game installation is enabled + + \ 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/StringResources.cs b/GenHub/GenHub.Core/Resources/Strings/StringResources.cs new file mode 100644 index 000000000..8b51f68f5 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/StringResources.cs @@ -0,0 +1,68 @@ +namespace GenHub.Core.Resources.Strings; + +/// +/// Provides strongly-typed access to string resource namespaces. +/// This class contains constants for all resource file base names used in the localization system. +/// +public static class StringResources +{ + /// + /// Base name for common UI resources (buttons, status messages, actions). + /// + public const string UiCommon = "GenHub.Core.Resources.Strings.UI.Common"; + + /// + /// Base name for navigation-related UI resources (tab names, section headers). + /// + public const string UiNavigation = "GenHub.Core.Resources.Strings.UI.Navigation"; + + /// + /// Base name for game profile management UI resources. + /// + public const string UiGameProfiles = "GenHub.Core.Resources.Strings.UI.GameProfiles"; + + /// + /// Base name for settings page UI resources. + /// + public const string UiSettings = "GenHub.Core.Resources.Strings.UI.Settings"; + + /// + /// Base name for update functionality UI resources. + /// + public const string UiUpdates = "GenHub.Core.Resources.Strings.UI.Updates"; + + /// + /// Base name for tools management UI resources. + /// + public const string UiTools = "GenHub.Core.Resources.Strings.UI.Tools"; + + /// + /// Base name for downloads UI resources. + /// + public const string UiDownloads = "GenHub.Core.Resources.Strings.UI.Downloads"; + + /// + /// Base name for validation error messages. + /// + public const string ErrorsValidation = "GenHub.Core.Resources.Strings.Errors.Validation"; + + /// + /// Base name for operation error messages. + /// + public const string ErrorsOperations = "GenHub.Core.Resources.Strings.Errors.Operations"; + + /// + /// Base name for success messages. + /// + public const string MessagesSuccess = "GenHub.Core.Resources.Strings.Messages.Success"; + + /// + /// Base name for confirmation dialog messages. + /// + public const string MessagesConfirmations = "GenHub.Core.Resources.Strings.Messages.Confirmations"; + + /// + /// Base name for UI tooltip resources. + /// + public const string Tooltips = "GenHub.Core.Resources.Strings.Tooltips"; +} \ 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..6acf2b9f7 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Common.resx @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + Launch + Button to launch a game or application + + + Stop + Button to stop a running process + + + Prepare + Button to prepare workspace + + + New + Button to create a new item + + + Scan + Button to scan for items + + + ✕ Remove + Button to remove an item from a list + + + + Add + Button to add an item to a list + + + + + version + Version label (lowercase) + + + by + Author attribution label (lowercase) + + + + + Loading content... + Status shown while loading content + + + Saving... + Status shown while saving + + + Processing... + Status shown while processing + + + Initializing... + Status shown during initialization + + + Ready + Status when system is ready + + + Complete + Status when operation is complete + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/UI.Downloads.resx b/GenHub/GenHub.Core/Resources/Strings/UI.Downloads.resx new file mode 100644 index 000000000..a858dd0c8 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Downloads.resx @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + Downloads + Page title for the Downloads tab + + + Manage your downloads and installations + Page subtitle for the Downloads tab + + + + + Primary Downloads + Section header for primary downloads area + + + Additional Content + Section header for additional content area + + + + + GitHub Builds + Download category for GitHub builds + + + Official Releases + Download category for official releases + + + Mod Downloads + Download category for mods + + + Maps + Download category for maps + + + Tools & Utilities + Download category for tools and utilities + + + Patches & Fixes + Download category for patches and fixes + + + + + (Coming Soon) + Label indicating a feature is coming soon, with parentheses + + + Coming Soon + Status text indicating a feature is coming soon + + + + + Downloading... + Status shown while downloading + + + Download Complete + Status shown when download is complete + + + Download Failed + Status shown when download fails + + + Paused + Status shown when download is paused + + + Queued + Status shown when download is queued + + + Installing... + Status shown while installing + + \ 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..ff0b8ca81 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.GameProfiles.resx @@ -0,0 +1,361 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + Game Profiles + Page title for the Game Profiles section + + + Select a profile to launch or create a new one + Page subtitle for the Game Profiles section + + + + + Add New Profile + Button to create a new game profile + + + + + Scanning... + Status shown during game scanning + + + Loaded {0} profiles + Status showing number of loaded profiles. {0} = count + + + Launching {0}... + Status when launching a profile. {0} = profile name + + + {0} launched successfully (Process ID: {1}) + Success message after launching. {0} = profile name, {1} = process ID + + + Stopping {0}... + Status when stopping a profile. {0} = profile name + + + {0} stopped successfully + Success message after stopping. {0} = profile name + + + Edit mode enabled + Status when edit mode is turned on + + + Edit mode disabled + Status when edit mode is turned off + + + Saving profiles... + Status while saving profiles + + + Profiles saved successfully + Success message after saving profiles + + + Deleting {0}... + Status when deleting a profile. {0} = profile name + + + {0} deleted successfully + Success message after deleting. {0} = profile name + + + Profile updated successfully + Success message after updating a profile + + + New profile window closed + Status when new profile window closes + + + Preparing workspace for {0}... + Status when preparing workspace. {0} = profile name + + + Workspace prepared for {0} at {1} + Success message after workspace preparation. {0} = profile name, {1} = path + + + + + Profile manager not available + Error when profile manager service is unavailable + + + Game Profile Manager service is not initialized + Error when profile manager is not initialized + + + Failed to load profiles: {0} + Error loading profiles. {0} = error details + + + Failed to launch {0}: {1} + Error launching profile. {0} = profile name, {1} = error + + + Error launching {0} + Generic error launching profile. {0} = profile name + + + Failed to stop {0}: {1} + Error stopping profile. {0} = profile name, {1} = error + + + Error saving profiles + Generic error saving profiles + + + Failed to delete {0}: {1} + Error deleting profile. {0} = profile name, {1} = error + + + Error deleting {0} + Generic error deleting profile. {0} = profile name + + + Profile settings not available + Error when profile settings are unavailable + + + Profile editor not available + Error when profile editor is unavailable + + + Failed to load profile: {0} + Error loading a specific profile. {0} = error details + + + Could not find main window to open settings + Error when main window cannot be found + + + Error creating new profile + Generic error creating a new profile + + + Profile launcher not available + Error when profile launcher is unavailable + + + Failed to prepare workspace for {0}: {1} + Error preparing workspace. {0} = profile name, {1} = error + + + Error preparing workspace for {0} + Generic error preparing workspace. {0} = profile name + + + + + Not Prepared + Workspace status when not yet prepared + + + Symlinked + Workspace status when using symlinks + + + Copied + Workspace status when files are copied + + + Hybrid + Workspace status when using hybrid strategy + + + Hard Linked + Workspace status when using hard links + + + Prepared + Generic workspace status when prepared + + + + + Steam + Steam installation platform name + + + EA App + EA App installation platform name + + + The First Decade + The First Decade installation type name + + + Wine + Wine/Linux installation platform name + + + Retail + Retail installation type name + + + CD/ISO + CD/ISO installation type name + + + PC Game + Default PC game installation type name + + + + + C&C Generals + Command and Conquer Generals game name + + + C&C Generals: Zero Hour + Command and Conquer Generals Zero Hour game name + + + + + ADMIN + Badge indicating admin privileges required + + \ 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..e74e9a882 --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Navigation.resx @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + Tools + Navigation tab name for tools section + + + + + C&C Generals Launcher + Application tagline shown in navigation + + \ 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..389e9df1a --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Settings.resx @@ -0,0 +1,407 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + Settings + Page title for the Settings section + + + Configure your GenHub preferences and application behavior + Page subtitle for the Settings section + + + + + ✓ Settings saved successfully! + Notification shown when settings are saved + + + + + 🎮 Game Configuration + Section header for game configuration settings + + + 📥 Downloads + Section header for download settings + + + 🎨 Appearance + Section header for appearance settings + + + ⚡ Performance + Section header for performance settings + + + 🔗 Content-Addressable Storage (CAS) + Section header for CAS settings + + + 🗄️ Storage + Section header for storage settings + + + 📂 Local Content Directories + Section header for content directories settings + + + 🌐 GitHub Discovery + Section header for GitHub discovery settings + + + + + Preferred Game Installation Path + Label for preferred game path setting + + + Cache Directory + Label for cache directory setting + + + Content Storage Directory + Label for content storage directory setting + + + Content Directories (one per line) + Label for content directories setting + + + GitHub Repositories (one per line, owner/repo) + Label for GitHub repositories setting + + + Memory Usage + Label for memory usage display + + + + + Auto-detect game installation path... + Placeholder for game path input + + + Default (platform-dependent) + Placeholder for platform-dependent default paths + + + Default (%AppData%/GenHub/cas-pool) + Placeholder for CAS root path + + + Default (%AppData%/GenHub/Content) + Placeholder for content storage path + + + e.g. C:\Games\GenHub\Mods + Example placeholder for mods directory + + + e.g. some-author/some-mod + Example placeholder for GitHub repository + + + + + Specify where you prefer games to be installed. Leave empty for auto-detection. + Description for game path setting + + + Choose how files are managed in game workspaces + Description for workspace strategy setting + + + Limit the number of simultaneous downloads to manage bandwidth usage (1-10) + Description for max concurrent downloads setting + + + Buffer size used for file downloads (4KB - 1MB, default: 80KB) + Description for buffer size setting + + + Timeout for each download operation (10-3600 seconds, default: 600 seconds) + Description for timeout setting + + + HTTP User-Agent string sent with download requests + Description for user agent setting + + + Continue downloads when the application is minimized or in the background + Description for background downloads setting + + + Choose between light and dark themes + Description for theme setting + + + Enable verbose logging for troubleshooting. May impact performance. + Description for detailed logging setting + + + Automatically check for GenHub updates when the application starts + Description for auto check updates setting + + + Specify a custom location for your settings file. Leave empty to use the default location. + Description for settings file path setting + + + Monitor application memory consumption + Description for memory usage display + + + Directory where deduplicated content is stored. All workspaces reference files from here. + Description for CAS root path setting + + + Maximum size for the CAS pool before garbage collection runs (1-1000 GB) + Description for max cache size setting + + + Number of simultaneous CAS operations (1-16, default: 4) + Description for CAS concurrent operations setting + + + Automatically clean up unreferenced content to free disk space + Description for automatic GC setting + + + How long to keep unreferenced content before deletion (1-365 days) + Description for GC grace period setting + + + How often to run automatic garbage collection (1-30 days) + Description for auto GC interval setting + + + Check file hashes to ensure data integrity (recommended, slight performance impact) + Description for verify integrity setting + + + Override the default cache directory for downloads and temp files. + Description for cache path setting + + + Override the default directory where downloaded content is stored permanently. + Description for content storage path setting + + + Specify directories to scan for local content. One path per line. + Description for content directories setting + + + Specify GitHub repositories to scan for releases. One owner/repo per line. + Description for GitHub repositories setting + + + + + Settings are automatically saved and will take effect immediately or on next restart as needed. + Hint about settings auto-save behavior + + + + + Reset to Defaults + Button to reset settings to default values + + \ No newline at end of file diff --git a/GenHub/GenHub.Core/Resources/Strings/UI.Tools.resx b/GenHub/GenHub.Core/Resources/Strings/UI.Tools.resx new file mode 100644 index 000000000..963a4716e --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Tools.resx @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + Tools + Page title for the Tools tab + + + Manage and use tool plugins to extend GenHub functionality + Page subtitle for the Tools tab + + + + + Installed Tools + Section label for installed tools list + + + Name + Field label for tool name + + + Version + Field label for tool version + + + Author + Field label for tool author + + + Description + Field label for tool description + + + Tool ID + Field label for tool identifier + + + + + ➕ Add Tool + Button to add a new tool plugin + + + 🔄 Refresh + Button to refresh the tools list + + + Close + Button to close a dialog + + + + + View Details + Context menu item to view tool details + + + Remove Tool + Context menu item to remove a tool + + + + + Tool Details + Dialog title for viewing tool details + + + Select Tool Plugin Assembly + File picker dialog title + + + + + Tool Plugin Assembly + File type description for DLL files + + + + + Loading... + Loading status message + + + No tools installed. Click 'Add Tool' to install a tool plugin. + Status when no tools are installed + + + Installing tool... + Status during tool installation + + + ✓ Tool '{0}' v{1} installed successfully. + Success message after installing a tool. {0} = tool name, {1} = version + + + Removing tool '{0}'... + Status during tool removal. {0} = tool name + + + ✓ Tool '{0}' removed successfully. + Success message after removing a tool. {0} = tool name + + + Refreshing tools... + Status during tools refresh + + + ✓ Refreshed {0} tool(s) successfully. + Success message after refreshing tools. {0} = tool count + + + + + No tools installed yet + Empty state title when no tools are installed + + + Click 'Add Tool' to install your first tool plugin + Empty state description when no tools are installed + + + Select a tool from the list + Prompt to select a tool from the sidebar + + + + + Expand Sidebar + Tooltip for expand sidebar button + + + Collapse Sidebar + Tooltip for collapse sidebar button + + + + + ✗ Failed to install tool: {0} + Error message when tool installation fails. {0} = error details + + + ✗ Failed to remove tool: {0} + Error message when tool removal fails. {0} = error details + + + ⚠ Failed to refresh tools: {0} + Error message when tools refresh fails. {0} = error details + + + ⚠ Failed to load tools: {0} + Error message when loading tools fails. {0} = error details + + + ✗ Error loading tool '{0}': {1} + Error message when a specific tool fails to load. {0} = tool name, {1} = error + + \ 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..d0d00318f --- /dev/null +++ b/GenHub/GenHub.Core/Resources/Strings/UI.Updates.resx @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + Update Available! + Heading when an update is available (with exclamation) + + + What's new: + Label for release notes section + + + This update includes bug fixes, performance improvements, and new features. + Default text for update description when no specific notes + + + You're up to date! + Status message when app is already on latest version + + + is ready to install + Partial text indicating version is ready + + + % complete + Partial text for percentage completion + + \ 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..1d5b4c986 --- /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 readonly object _lock = new(); + private IReadOnlyList? _cachedCultures; + + /// + /// 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 diff --git a/GenHub/GenHub.Core/Services/Localization/LocalizationService.cs b/GenHub/GenHub.Core/Services/Localization/LocalizationService.cs new file mode 100644 index 000000000..26ced4fc4 --- /dev/null +++ b/GenHub/GenHub.Core/Services/Localization/LocalizationService.cs @@ -0,0 +1,313 @@ +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; + + /// + /// Gets a localized string from the specified resource set. + /// + /// The resource set base name (e.g., from StringResources constants). + /// The resource key. + /// The localized string, or a fallback if not found. + public string GetString(string resourceSet, string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceSet, nameof(resourceSet)); + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(key)); + + try + { + var resourceManager = _languageProvider.GetResourceManager(resourceSet); + + 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} from resource set: {ResourceSet}", key, resourceSet); + return HandleMissingTranslation(key); + } + } + + /// + /// Gets a formatted localized string from the specified resource set. + /// + /// The resource set base name (e.g., from StringResources constants). + /// The resource key. + /// Format arguments. + /// The formatted localized string, or a fallback if not found. + public string GetString(string resourceSet, string key, params object[] args) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resourceSet, nameof(resourceSet)); + ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(key)); + + try + { + var format = GetString(resourceSet, 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}' from resource set '{ResourceSet}' with {ArgCount} arguments", + key, + resourceSet, + args?.Length ?? 0); + + return HandleMissingTranslation(key); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error formatting resource string for key: {Key} from resource set: {ResourceSet}", key, resourceSet); + 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); + } + + /// + /// 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; + } + + /// + /// 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; + } +} \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/StringResourcesTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/StringResourcesTests.cs new file mode 100644 index 000000000..e0bb23b50 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Resources/StringResourcesTests.cs @@ -0,0 +1,263 @@ +using GenHub.Core.Interfaces.Localization; +using GenHub.Core.Resources.Strings; +using GenHub.Core.Services.Localization; +using Microsoft.Extensions.Logging; +using Moq; +using System.Globalization; +using System.Resources; +using Xunit; + +namespace GenHub.Tests.Core.Resources; + +/// +/// Integration tests for string resource loading and localization infrastructure. +/// Tests that .resx files are properly embedded, ResourceManagers can load them, +/// and the LocalizationService can retrieve strings. +/// +public class StringResourcesTests +{ + private readonly ILanguageProvider _languageProvider; + private readonly ILocalizationService _localizationService; + + public StringResourcesTests() + { + var loggerLanguageProvider = new Mock>(); + var loggerLocalizationService = new Mock>(); + + _languageProvider = new LanguageProvider(loggerLanguageProvider.Object); + _localizationService = new LocalizationService( + _languageProvider, + loggerLocalizationService.Object); + } + + [Fact] + public void AllResourceSetsAreEmbedded() + { + // Arrange & Act - Try to create ResourceManager for each resource set + var resourceSets = new[] + { + StringResources.UiCommon, + StringResources.UiNavigation, + StringResources.UiGameProfiles, + StringResources.UiSettings, + StringResources.UiUpdates, + StringResources.ErrorsValidation, + StringResources.ErrorsOperations, + StringResources.MessagesSuccess, + StringResources.MessagesConfirmations, + StringResources.Tooltips + }; + + foreach (var resourceSet in resourceSets) + { + // Assert - Should not throw exception + var resourceManager = _languageProvider.GetResourceManager(resourceSet); + Assert.NotNull(resourceManager); + } + } + + [Fact] + public void CanLoadStringFromUiCommon() + { + // Arrange + var resourceManager = _languageProvider.GetResourceManager(StringResources.UiCommon); + + // Act + var saveButton = resourceManager.GetString("Button.Save", CultureInfo.GetCultureInfo("en")); + + // Assert + Assert.NotNull(saveButton); + Assert.Equal("Save", saveButton); + } + + [Fact] + public void CanLoadStringFromUiNavigation() + { + // Arrange + var resourceManager = _languageProvider.GetResourceManager(StringResources.UiNavigation); + + // Act + var gameProfilesTab = resourceManager.GetString("Tab.GameProfiles", CultureInfo.GetCultureInfo("en")); + + // Assert + Assert.NotNull(gameProfilesTab); + Assert.Equal("Game Profiles", gameProfilesTab); + } + + [Fact] + public void CanLoadStringFromErrorsValidation() + { + // Arrange + var resourceManager = _languageProvider.GetResourceManager(StringResources.ErrorsValidation); + + // Act + var requiredField = resourceManager.GetString("RequiredField", CultureInfo.GetCultureInfo("en")); + + // Assert + Assert.NotNull(requiredField); + Assert.Equal("This field is required", requiredField); + } + + [Fact] + public void LocalizationService_CanRetrieveStringWithResourceSet() + { + // Act + var saveButton = _localizationService.GetString(StringResources.UiCommon, "Button.Save"); + + // Assert + Assert.NotNull(saveButton); + Assert.Equal("Save", saveButton); + } + + [Fact] + public void LocalizationService_CanFormatStringWithArguments() + { + // Act + var scanComplete = _localizationService.GetString( + StringResources.UiGameProfiles, + "Status.ScanComplete", + 5); + + // Assert + Assert.NotNull(scanComplete); + Assert.Contains("5", scanComplete); + Assert.Contains("game installations", scanComplete); + } + + [Fact] + public void LocalizationService_ReturnsPlaceholderForMissingKey() + { + // Act + var result = _localizationService.GetString(StringResources.UiCommon, "NonExistentKey"); + + // Assert + Assert.NotNull(result); + Assert.Contains("NonExistentKey", result); + } + + [Fact] + public void LanguageProvider_CanDiscoverEnglishCulture() + { + // Act + var cultures = _languageProvider.DiscoverAvailableLanguages().GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(cultures); + Assert.NotEmpty(cultures); + Assert.Contains(cultures, c => c.TwoLetterISOLanguageName == "en"); + } + + [Fact] + public void LanguageProvider_ValidatesEnglishCulture() + { + // Arrange + var englishCulture = CultureInfo.GetCultureInfo("en"); + + // Act + var isValid = _languageProvider.ValidateCulture(englishCulture); + + // Assert + Assert.True(isValid); + } + + [Theory] + [InlineData(StringResources.UiCommon, "Button.Cancel", "Cancel")] + [InlineData(StringResources.UiCommon, "Button.OK", "OK")] + [InlineData(StringResources.UiCommon, "Status.Loading", "Loading...")] + [InlineData(StringResources.UiNavigation, "Tab.Downloads", "Downloads")] + [InlineData(StringResources.UiNavigation, "Tab.Settings", "Settings")] + [InlineData(StringResources.UiSettings, "Button.SaveSettings", "Save Settings")] + [InlineData(StringResources.UiUpdates, "Status.ReadyToCheck", "Ready to check for updates")] + [InlineData(StringResources.ErrorsValidation, "InvalidFormat", "Invalid format")] + [InlineData(StringResources.ErrorsOperations, "OperationFailed", "Operation failed")] + [InlineData(StringResources.MessagesSuccess, "OperationCompleted", "Operation completed successfully")] + [InlineData(StringResources.MessagesConfirmations, "DeleteProfile", "Are you sure you want to delete this profile?")] + [InlineData(StringResources.Tooltips, "Button.Save", "Save changes")] + public void CanLoadSpecificStringsFromAllResourceSets(string resourceSet, string key, string expected) + { + // Act + var result = _localizationService.GetString(resourceSet, key); + + // Assert + Assert.NotNull(result); + Assert.Equal(expected, result); + } + + [Fact] + public void ResourceManager_IsCachedByLanguageProvider() + { + // Act + var rm1 = _languageProvider.GetResourceManager(StringResources.UiCommon); + var rm2 = _languageProvider.GetResourceManager(StringResources.UiCommon); + + // Assert - Should return the same instance (cached) + Assert.Same(rm1, rm2); + } + + [Fact] + public void LocalizationService_CurrentCultureIsEnglishByDefault() + { + // Act + var currentCulture = _localizationService.CurrentCulture; + + // Assert + Assert.NotNull(currentCulture); + Assert.Equal("en", currentCulture.TwoLetterISOLanguageName); + } + + [Fact] + public void AllResourceSetsContainMultipleStrings() + { + // Arrange + var resourceSets = new[] + { + StringResources.UiCommon, + StringResources.UiNavigation, + StringResources.UiGameProfiles, + StringResources.UiSettings, + StringResources.UiUpdates, + StringResources.ErrorsValidation, + StringResources.ErrorsOperations, + StringResources.MessagesSuccess, + StringResources.MessagesConfirmations, + StringResources.Tooltips + }; + + foreach (var resourceSetName in resourceSets) + { + // Act + var resourceManager = _languageProvider.GetResourceManager(resourceSetName); + var resourceSet = resourceManager.GetResourceSet(CultureInfo.GetCultureInfo("en"), true, true); + + // Assert + Assert.NotNull(resourceSet); + + var enumerator = resourceSet.GetEnumerator(); + var count = 0; + while (enumerator.MoveNext()) + { + count++; + } + + // Each resource file should have at least 5 strings + // Note: Some resource sets like UI.Navigation naturally have fewer entries + Assert.True(count >= 5, $"{resourceSetName} should have at least 5 strings, but has {count}"); + } + } + + [Fact] + public void FormatStrings_WorkWithMultipleParameters() + { + // Act + var profileDescription = _localizationService.GetString( + StringResources.UiGameProfiles, + "Profile.AutoCreatedDescription", + "Steam", + "C:\\Games\\MyGame"); + + // Assert + Assert.NotNull(profileDescription); + Assert.Contains("Steam", profileDescription); + Assert.Contains("C:\\Games\\MyGame", profileDescription); + } +} \ No newline at end of file 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..4d8110dfc --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Services/Localization/LocalizationServiceTests.cs @@ -0,0 +1,408 @@ +using System.Globalization; +using System.Reactive.Linq; +using GenHub.Core.Interfaces.Localization; +using GenHub.Core.Models.Localization; +using GenHub.Core.Resources.Strings; +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(StringResources.UiCommon, null!)); + } + + [Fact] + public void GetString_WithEmptyKey_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => _service.GetString(StringResources.UiCommon, string.Empty)); + } + + [Fact] + public void GetString_WithWhitespaceKey_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => _service.GetString(StringResources.UiCommon, " ")); + } + + [Fact] + public void GetString_WithMissingKey_ReturnsKeyWithMarker() + { + // Arrange + var key = "NonExistentKey"; + + // Act + var result = _service.GetString(StringResources.UiCommon, 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(StringResources.UiCommon, key, args); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetString_WithNullParameters_ReturnsUnformattedString() + { + // Arrange + var key = "TestKey"; + + // Act + var result = _service.GetString(StringResources.UiCommon, key, (object[])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(StringResources.UiCommon, 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(StringResources.UiCommon, key); + + // Assert + Assert.Equal(key, result); + Assert.DoesNotContain("[", result); + Assert.DoesNotContain("]", result); + } +} \ No newline at end of file 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 diff --git a/docs/architecture/localization-system-design.md b/docs/architecture/localization-system-design.md new file mode 100644 index 000000000..8a5dd0706 --- /dev/null +++ b/docs/architecture/localization-system-design.md @@ -0,0 +1,1808 @@ +# GenHub Localization System Architecture Design + +## 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` is 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 + + + + + + +