Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
95e75b6
Add localization service and language provider interfaces
OmarAglan Oct 9, 2025
a1e78aa
Add localization options and language provider
OmarAglan Oct 9, 2025
9aed532
Add localization service and extension methods
OmarAglan Oct 9, 2025
d5094ce
Add localization services DI module
OmarAglan Oct 9, 2025
a79fffd
Add unit tests for localization services
OmarAglan Oct 10, 2025
dc1ea87
Add localization resource files and documentation
OmarAglan Nov 6, 2025
83ceb76
Enable satellite assemblies and embed resource files
OmarAglan Nov 6, 2025
cfb6c25
Add strongly-typed string resource constants
OmarAglan Nov 6, 2025
7d1de7b
Add resource set support to LocalizationService
OmarAglan Nov 6, 2025
8bb5f93
Add localization resource tests and documentation
OmarAglan Nov 6, 2025
98872a4
Add resource set overloads to ILocalizationService
OmarAglan Nov 6, 2025
410628f
Lower minimum string count in resource file test
OmarAglan Nov 6, 2025
5e6408f
Add UI resource file for Tools tab
OmarAglan Dec 7, 2025
18a5e20
Add UI resource file for Downloads tab
OmarAglan Dec 7, 2025
435b923
Add new UI strings for buttons, statuses, and settings
OmarAglan Dec 7, 2025
e4f370b
Add new update dialog strings to UI resources
OmarAglan Dec 7, 2025
3e7f6d8
Add new error and validation messages, update resource keys
OmarAglan Dec 7, 2025
ed11c6e
Escape ampersands in resource strings and update docs
OmarAglan Dec 7, 2025
675ed25
Refactor localization service and update docs
OmarAglan Dec 23, 2025
558d51e
Remove obsolete overloads from ILocalizationService
OmarAglan Dec 23, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions GenHub/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageVersion Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageVersion Include="Slugify.Core" Version="5.1.1" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="System.Reactive" Version="6.0.0" />
<PackageVersion Include="CsvHelper" Version="33.1.0" />
<!-- Test packages -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
Expand Down
300 changes: 300 additions & 0 deletions GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs
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(
Copy link
Copy Markdown
Contributor

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 your out param, the compiler would know that the value is non-null as the method returned true for its bool. Then you could make your method like this:

public static bool TryGetString(
    this ILocalizationService service,
    string key,
    [NotNullWhen(true)] out string? value)
{
    ArgumentNullException.ThrowIfNull(service, nameof(service));
    ArgumentException.ThrowIfNullOrWhiteSpace(key, nameof(key));

    value = null;

    try
    {
        var result = service.GetString(StringResources.UiCommon, key);

        // Check if we got a missing translation marker
        if (result.StartsWith('[') && result.EndsWith(']'))
            return false;

        value = result;
        return true;
    }
    catch
    {
        return false;
    }
}

and you wouldn't have to check for value is not null after calling this method, when it returned true.

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this one only handles Dates to format, why not use DateOnly to make it explicit that you don't care about the time part? Or at least include an example in the <returns> of the two formats to expect (one with and one without a format specified)

string? format = null)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

View workflow job for this annotation

GitHub Actions / Build Linux

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 64 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Linux

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 64 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 64 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 64 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)
: 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

View workflow job for this annotation

GitHub Actions / Build Linux

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 84 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Linux

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 84 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 84 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 84 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment about DateOnly, but now for TimeOnly.

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

View workflow job for this annotation

GitHub Actions / Build Linux

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 104 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Linux

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 104 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 104 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)

Check warning on line 104 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Code should not contain multiple whitespace characters in a row (https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SA1025.md)
: 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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 string. This too seems like a good candidate for an Enum, to limit it to allowed options (or at least ones that makes sense)

{
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>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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" };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 GitHubModelExtensions.GetFormattedSizes(this GitHubArtifact artifact). This one also uses existing constants, instead of "magic strings" like you are here. Could you also use constants for this, or re-use the ones from this method?
Maybe once this is merged, the method I just mentioned and this one can be merged, to remove the code duplication.

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

View workflow job for this annotation

GitHub Actions / Build Linux

Check warning on line 258 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Linux

Check warning on line 258 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Check warning on line 258 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

Check warning on line 258 in GenHub/GenHub.Core/Extensions/Localization/LocalizationExtensions.cs

View workflow job for this annotation

GitHub Actions / Build Windows

};

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;
}
}
8 changes: 8 additions & 0 deletions GenHub/GenHub.Core/GenHub.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" />
Expand All @@ -12,5 +13,12 @@
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" />
<PackageReference Include="CsvHelper" />
<PackageReference Include="System.Reactive" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Resources\Strings\*.resx">
<Generator>ResXFileCodeGenerator</Generator>
<CustomToolNamespace>GenHub.Core.Resources.Strings</CustomToolNamespace>
</EmbeddedResource>
</ItemGroup>
</Project>
30 changes: 30 additions & 0 deletions GenHub/GenHub.Core/Interfaces/Localization/ILanguageProvider.cs
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rename this to IsCultureAvailable(), as this method's name makes it seem as if the method is going to validate the culture provided, but it doesn't.

}
Loading