-
Notifications
You must be signed in to change notification settings - Fork 20
feat(localization): Core localization infrastructure with reactive culture switching #161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
95e75b6
a1e78aa
9aed532
d5094ce
a79fffd
dc1ea87
83ceb76
cfb6c25
7d1de7b
8bb5f93
98872a4
410628f
5e6408f
18a5e20
435b923
e4f370b
3e7f6d8
ed11c6e
675ed25
558d51e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,300 @@ | ||
| using System.Globalization; | ||
| using GenHub.Core.Interfaces.Localization; | ||
| using GenHub.Core.Resources.Strings; | ||
|
|
||
| namespace GenHub.Core.Extensions.Localization; | ||
|
|
||
| /// <summary> | ||
| /// Extension methods for localization services and culture-aware formatting. | ||
| /// </summary> | ||
| public static class LocalizationExtensions | ||
| { | ||
| /// <summary> | ||
| /// Tries to get a localized string, returning a success indicator. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="key">The resource key.</param> | ||
| /// <param name="value">The localized string if found; otherwise, null.</param> | ||
| /// <returns>True if the string was found; otherwise, false.</returns> | ||
| 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; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Formats a date using the current culture's date format. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="date">The date to format.</param> | ||
| /// <param name="format">Optional format string (defaults to short date pattern).</param> | ||
| /// <returns>The formatted date string.</returns> | ||
| public static string FormatDate( | ||
| this ILocalizationService service, | ||
| DateTime date, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this one only handles |
||
| string? format = null) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are there any formats that you do NOT accept? If so, can these be listed anywhere, or the argument constrained to e.g. an Enum with allowed values? |
||
| { | ||
| ArgumentNullException.ThrowIfNull(service, nameof(service)); | ||
|
|
||
| var culture = service.CurrentCulture; | ||
| return string.IsNullOrWhiteSpace(format) | ||
| ? date.ToString("d", culture) // Short date pattern | ||
|
Check warning on line 64 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs
|
||
| : date.ToString(format, culture); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Formats a date and time using the current culture's format. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="dateTime">The date and time to format.</param> | ||
| /// <param name="format">Optional format string (defaults to short date + time pattern).</param> | ||
| /// <returns>The formatted date and time string.</returns> | ||
| 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 | ||
|
Check warning on line 84 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are some warnings here about the code containing multiple whitespace characters. Please resolve these. |
||
| : dateTime.ToString(format, culture); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Formats a time using the current culture's time format. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="time">The time to format.</param> | ||
| /// <param name="format">Optional format string (defaults to short time pattern).</param> | ||
| /// <returns>The formatted time string.</returns> | ||
| public static string FormatTime( | ||
| this ILocalizationService service, | ||
| DateTime time, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See comment about |
||
| string? format = null) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(service, nameof(service)); | ||
|
|
||
| var culture = service.CurrentCulture; | ||
| return string.IsNullOrWhiteSpace(format) | ||
| ? time.ToString("t", culture) // Short time pattern | ||
|
Check warning on line 104 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs
|
||
| : time.ToString(format, culture); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Formats a number using the current culture's number format. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="number">The number to format.</param> | ||
| /// <param name="decimals">Optional number of decimal places.</param> | ||
| /// <returns>The formatted number string.</returns> | ||
| 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); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Formats a number using the current culture's number format. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="number">The number to format.</param> | ||
| /// <param name="decimals">Optional number of decimal places.</param> | ||
| /// <returns>The formatted number string.</returns> | ||
| 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); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Formats a currency value using the current culture's currency format. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="amount">The amount to format.</param> | ||
| /// <param name="currencyCode">Optional ISO currency code (e.g., "USD", "EUR"). If null, uses culture's default.</param> | ||
| /// <returns>The formatted currency string.</returns> | ||
| public static string FormatCurrency( | ||
| this ILocalizationService service, | ||
| decimal amount, | ||
| string? currencyCode = null) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure whether an ISO currency code should just be a |
||
| { | ||
| 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); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Formats a percentage using the current culture's percentage format. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="value">The value to format (e.g., 0.85 for 85%).</param> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if a number > 1 is used here? Should it endlessly go up in percentages, or should it do something in that case? A value of "20" will be turned into "2,000", which might be unexpected for other developers. |
||
| /// <param name="decimals">Optional number of decimal places.</param> | ||
| /// <returns>The formatted percentage string.</returns> | ||
| 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); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Formats a file size in a human-readable format using current culture. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="bytes">The size in bytes.</param> | ||
| /// <returns>The formatted file size string (e.g., "1.5 MB").</returns> | ||
| public static string FormatFileSize( | ||
| this ILocalizationService service, | ||
| long bytes) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(service, nameof(service)); | ||
|
|
||
| string[] sizes = { "B", "KB", "MB", "GB", "TB" }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a 1:1 existing method for this, with the only difference being the method arguments and the usage of culture in here. See |
||
| 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]}"; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets a formatted string for plural forms based on count. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <param name="count">The count to determine plurality.</param> | ||
| /// <param name="zeroKey">The resource key for zero items.</param> | ||
| /// <param name="oneKey">The resource key for one item.</param> | ||
| /// <param name="manyKey">The resource key for many items.</param> | ||
| /// <returns>The appropriate pluralized string.</returns> | ||
| 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 | ||
|
Check warning on line 258 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs
|
||
| }; | ||
|
|
||
| return service.GetString(StringResources.UiCommon, key, count); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Checks if a culture is Right-to-Left (RTL). | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <returns>True if the current culture is RTL; otherwise, false.</returns> | ||
| public static bool IsRightToLeft(this ILocalizationService service) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(service, nameof(service)); | ||
|
|
||
| var culture = service.CurrentCulture; | ||
| return culture.TextInfo.IsRightToLeft; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the display name of the current culture in its native language. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <returns>The native name of the current culture.</returns> | ||
| public static string GetCurrentCultureNativeName(this ILocalizationService service) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(service, nameof(service)); | ||
|
|
||
| return service.CurrentCulture.NativeName; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the display name of the current culture in English. | ||
| /// </summary> | ||
| /// <param name="service">The localization service.</param> | ||
| /// <returns>The English name of the current culture.</returns> | ||
| public static string GetCurrentCultureEnglishName(this ILocalizationService service) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(service, nameof(service)); | ||
|
|
||
| return service.CurrentCulture.EnglishName; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| using System.Globalization; | ||
| using System.Resources; | ||
|
|
||
| namespace GenHub.Core.Interfaces.Localization; | ||
|
|
||
| /// <summary> | ||
| /// Manages resource discovery and resource manager provisioning for localization. | ||
| /// </summary> | ||
| public interface ILanguageProvider | ||
| { | ||
| /// <summary> | ||
| /// Discovers all available satellite assembly cultures. | ||
| /// </summary> | ||
| /// <returns>A task that yields a list of available cultures.</returns> | ||
| Task<IReadOnlyList<CultureInfo>> DiscoverAvailableLanguages(); | ||
|
|
||
| /// <summary> | ||
| /// Gets a ResourceManager for the specified base name. | ||
| /// </summary> | ||
| /// <param name="baseName">The base name of the resource (e.g., "GenHub.Core.Resources.Strings").</param> | ||
| /// <returns>The ResourceManager instance.</returns> | ||
| ResourceManager GetResourceManager(string baseName); | ||
|
|
||
| /// <summary> | ||
| /// Validates that a culture is available in the application. | ||
| /// </summary> | ||
| /// <param name="culture">The culture to validate.</param> | ||
| /// <returns>True if the culture is available; otherwise, false.</returns> | ||
| bool ValidateCulture(CultureInfo culture); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would rename this to |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like that you used the 'default'
bool Try[...](out [...] value)structure here, instead of reinventing it. That's good!I do have a slight tip for you, that might make working with these kinds of methods a bit easier: if you added
[NotNullWhen(true)]to youroutparam, the compiler would know that thevalueis non-null as the method returnedtruefor itsbool. Then you could make your method like this:and you wouldn't have to check for
value is not nullafter calling this method, when it returnedtrue.