diff --git a/WheelWizard.Test/Features/Localization/PluralRulesTests.cs b/WheelWizard.Test/Features/Localization/PluralRulesTests.cs new file mode 100644 index 000000000..f2632dbc8 --- /dev/null +++ b/WheelWizard.Test/Features/Localization/PluralRulesTests.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using WheelWizard.Localization; + +namespace WheelWizard.Test.Features.Localization; + +[Collection("SettingsFeature")] +public class PluralRulesTests +{ + [Theory] + [InlineData("1", PluralCategory.One)] + [InlineData("1.0", PluralCategory.One)] + [InlineData("0", PluralCategory.Other)] + [InlineData("2", PluralCategory.Other)] + [InlineData("1.5", PluralCategory.Other)] + [InlineData("-1", PluralCategory.Other)] + [InlineData("21", PluralCategory.Other)] + public void SelectsSimpleCategory(string count, PluralCategory expected) => + Assert.Equal(expected, PluralRules.Select(decimal.Parse(count, CultureInfo.InvariantCulture))); + + [Fact] + public void GlobalTranslationSupportsNamedAndPositionalCountsAndLanguageFallback() + { + var previous = LocalizationProvider.Current; + var service = new EmbeddedYamlLocalizationService(typeof(PluralRulesTests).Assembly); + LocalizationProvider.Use(service); + try + { + Assert.Equal("One item for Alex", TranslationFunctions.t("items", count: 1, "Alex")); + Assert.Equal("2 items for Alex", TranslationFunctions.t("items", 2, "Alex")); + Assert.Equal("Value 3", TranslationFunctions.t("plain", 3)); + service.SetLanguage("ru"); + Assert.Equal("ru other", TranslationFunctions.t("items", count: 22)); + Assert.Equal("English other", TranslationFunctions.t("fallback", count: 22)); + Assert.Equal("English other", TranslationFunctions.t("en.fallback", count: 0)); + } + finally + { + LocalizationProvider.Use(previous); + } + } + + [Theory] + [InlineData("pt", "pt other")] + [InlineData("pt-BR", "pt other")] + [InlineData("pt-PT", "pt other")] + [InlineData("PT_pt", "pt other")] + public void GlobalTranslationUsesTheSameRuleForEveryRegion(string locale, string expected) + { + var previous = LocalizationProvider.Current; + var service = new EmbeddedYamlLocalizationService(typeof(PluralRulesTests).Assembly); + LocalizationProvider.Use(service); + try + { + Assert.Equal(expected, TranslationFunctions.t($"{locale}.items", count: 0)); + Assert.Equal("English other", TranslationFunctions.t($"{locale}.fallback", count: 0)); + Assert.Equal("pt one", TranslationFunctions.t($"{locale}.items", count: 1)); + service.SetLanguage(locale); + Assert.Equal(expected, TranslationFunctions.t("items", count: 0)); + } + finally + { + LocalizationProvider.Use(previous); + } + } + + [Fact] + public void LegacyNumericKeysRemainUnchanged() + { + var previous = LocalizationProvider.Current; + LocalizationProvider.Use(new EmbeddedYamlLocalizationService()); + try + { + Assert.Equal("1 day", TranslationFunctions.t_legacy("en.time.days.n", 1)); + Assert.Equal("2 days", TranslationFunctions.t_legacy("en.time.days.n", 2)); + } + finally + { + LocalizationProvider.Use(previous); + } + } +} diff --git a/WheelWizard.Test/Features/Settings/SettingsTests.cs b/WheelWizard.Test/Features/Settings/SettingsTests.cs index 2aa3245bf..fe0e1ae20 100644 --- a/WheelWizard.Test/Features/Settings/SettingsTests.cs +++ b/WheelWizard.Test/Features/Settings/SettingsTests.cs @@ -349,8 +349,8 @@ public void TranslationFunction_UsesSpecificNumberVariant_WhenItExists() { localizationService.SetLanguage("en"); - Assert.Equal("1 day", TranslationFunctions.t("time.days.n", 1)); - Assert.Equal("2 days", TranslationFunctions.t("time.days.n", 2)); + Assert.Equal("1 day", TranslationFunctions.t_legacy("time.days.n", 1)); + Assert.Equal("2 days", TranslationFunctions.t_legacy("time.days.n", 2)); } finally { diff --git a/WheelWizard.Test/Resources/Languages/plural-fixture.yml b/WheelWizard.Test/Resources/Languages/plural-fixture.yml new file mode 100644 index 000000000..b820e5b1a --- /dev/null +++ b/WheelWizard.Test/Resources/Languages/plural-fixture.yml @@ -0,0 +1,16 @@ +en: + items: + one: "One item for {$2}" + other: "{$1} items for {$2}" + fallback: + one: "English one" + other: "English other" + plain: "Value {$1}" +ru: + items: + one: "ru one" + few: "ru few" + many: "ru many" + other: "ru other" + fallback: + one: "ru one" diff --git a/WheelWizard.Test/Resources/Languages/pt.yml b/WheelWizard.Test/Resources/Languages/pt.yml new file mode 100644 index 000000000..197276ccf --- /dev/null +++ b/WheelWizard.Test/Resources/Languages/pt.yml @@ -0,0 +1,4 @@ +pt: + items: + one: "pt one" + other: "pt other" diff --git a/WheelWizard.Test/WheelWizard.Test.csproj b/WheelWizard.Test/WheelWizard.Test.csproj index 191070897..a7fc24521 100644 --- a/WheelWizard.Test/WheelWizard.Test.csproj +++ b/WheelWizard.Test/WheelWizard.Test.csproj @@ -34,4 +34,7 @@ + + + diff --git a/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs b/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs index d0897b193..92e58b84d 100644 --- a/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs +++ b/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs @@ -13,7 +13,7 @@ public sealed class EmbeddedYamlLocalizationService : ILocalizationService public EmbeddedYamlLocalizationService() : this(typeof(EmbeddedYamlLocalizationService).Assembly) { } - internal EmbeddedYamlLocalizationService(Assembly resourceAssembly) + public EmbeddedYamlLocalizationService(Assembly resourceAssembly) { _translations = LoadTranslations(resourceAssembly); if (_translations.Count == 0) @@ -49,6 +49,22 @@ public string Translate(string key) return TranslateForLanguage(key, CurrentLanguage); } + public string TranslatePlural(string key, decimal count, string? languageCode = null) + { + var language = NormalizeLanguage(languageCode ?? CurrentLanguage); + var category = PluralRules.Select(count).ToString().ToLowerInvariant(); + foreach (var candidate in new[] { language, DefaultLanguage }.Distinct()) + { + if ( + TryGetValue(candidate, $"{key}.{category}", out var value) + || TryGetValue(candidate, $"{key}.other", out value) + || TryGetValue(candidate, key, out value) + ) + return value; + } + return key; + } + public string TranslateForLanguage(string key, string languageCode) { if (string.IsNullOrWhiteSpace(key)) diff --git a/WheelWizard/Features/Localization/ILocalizationService.cs b/WheelWizard/Features/Localization/ILocalizationService.cs index 85a49afe9..5c3540aff 100644 --- a/WheelWizard/Features/Localization/ILocalizationService.cs +++ b/WheelWizard/Features/Localization/ILocalizationService.cs @@ -7,6 +7,7 @@ public interface ILocalizationService void SetLanguage(string languageCode); string Translate(string key); + string TranslatePlural(string key, decimal count, string? languageCode = null); string TranslateForLanguage(string key, string languageCode); bool TryTranslateForLanguage(string key, string languageCode, out string value); bool HasLanguage(string languageCode); diff --git a/WheelWizard/Features/Localization/PluralRules.cs b/WheelWizard/Features/Localization/PluralRules.cs new file mode 100644 index 000000000..a777eb52f --- /dev/null +++ b/WheelWizard/Features/Localization/PluralRules.cs @@ -0,0 +1,13 @@ +namespace WheelWizard.Localization; + +public enum PluralCategory +{ + One, + Other, +} + +public static class PluralRules +{ + // Every language uses the same simple rule, including decimal counts such as 1.0. + public static PluralCategory Select(decimal count) => count == 1 ? PluralCategory.One : PluralCategory.Other; +} diff --git a/WheelWizard/Features/Localization/TranslationFunctions.cs b/WheelWizard/Features/Localization/TranslationFunctions.cs index e000a2caf..4b87418fa 100644 --- a/WheelWizard/Features/Localization/TranslationFunctions.cs +++ b/WheelWizard/Features/Localization/TranslationFunctions.cs @@ -2,8 +2,44 @@ namespace WheelWizard.Localization; public static class TranslationFunctions { -#pragma warning disable IDE1006 // Naming Styles public static string t(string key, params object?[] args) + { + if (args.Length > 0 && TryCount(args[0], out var count)) + return TranslateCount(key, count, args); + var prefixed = TrySplitLanguageKey(key, out var language, out var translationKey); + return Format( + prefixed ? LocalizationProvider.TranslateForLanguage(translationKey, language) : LocalizationProvider.Translate(translationKey), + args + ); + } + + /// Count supplies {$1}; additional arguments supply {$2}, {$3}, etc. + public static string t(string key, decimal count, params object?[] args) => TranslateCount(key, count, [count, .. args]); + + private static string TranslateCount(string key, decimal count, object?[] args) + { + var prefixed = TrySplitLanguageKey(key, out var language, out var translationKey); + return Format(LocalizationProvider.Current.TranslatePlural(translationKey, count, prefixed ? language : null), args); + } + + private static bool TryCount(object? value, out decimal count) + { + count = 0; + if (value is not (sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal)) + return false; + try + { + count = Convert.ToDecimal(value, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + catch (OverflowException) + { + return false; + } + } + +#pragma warning disable IDE1006 // Naming Styles + public static string t_legacy(string key, params object?[] args) #pragma warning restore IDE1006 // Naming Styles { var hasLanguagePrefix = TrySplitLanguageKey(key, out var languageCode, out var translationKey); @@ -33,11 +69,11 @@ public static string tTime(TimeSpan timeSpan) { var days = timeSpan.Days; var hours = timeSpan.Hours; - var dayText = t("time.days.n", days); + var dayText = t_legacy("time.days.n", days); if (hours == 0) return dayText; - var hourText = t("time.hours.n", hours); + var hourText = t_legacy("time.hours.n", hours); return $"{dayText} {hourText}"; } @@ -45,11 +81,11 @@ public static string tTime(TimeSpan timeSpan) { var hours = timeSpan.Hours; var minutes = timeSpan.Minutes; - var hourText = t("time.hours.n", hours); + var hourText = t_legacy("time.hours.n", hours); if (minutes == 0) return hourText; - var minuteText = t("time.minutes.n", minutes); + var minuteText = t_legacy("time.minutes.n", minutes); return $"{hourText} {minuteText}"; } @@ -57,15 +93,15 @@ public static string tTime(TimeSpan timeSpan) { var minutes = timeSpan.Minutes; var seconds = timeSpan.Seconds; - var minuteText = t("time.minutes.n", minutes); + var minuteText = t_legacy("time.minutes.n", minutes); if (seconds == 0) return minuteText; - var secondText = t("time.seconds.n", seconds); + var secondText = t_legacy("time.seconds.n", seconds); return $"{minuteText} {secondText}"; } - return t("time.seconds.n", timeSpan.Seconds); + return t_legacy("time.seconds.n", timeSpan.Seconds); } private static string ResolveNumberVariant(string translationKey, string languageCode, object?[] args) diff --git a/WheelWizard/Views/Layout.axaml.cs b/WheelWizard/Views/Layout.axaml.cs index 5c11b8f1a..e558bfe8a 100644 --- a/WheelWizard/Views/Layout.axaml.cs +++ b/WheelWizard/Views/Layout.axaml.cs @@ -268,7 +268,7 @@ public void UpdateFriendCount() { var friends = GameLicenseService.ActiveCurrentFriends; FriendsButton.BoxText = $"{friends.Count(friend => friend.IsOnline)}/{friends.Count}"; - FriendsButton.BoxTip = t("hover.friends_online.n", friends.Count(friend => friend.IsOnline)); + FriendsButton.BoxTip = t_legacy("hover.friends_online.n", friends.Count(friend => friend.IsOnline)); } public void UpdateSidebarProfile() @@ -292,7 +292,7 @@ public void UpdatePlayerAndRoomCount(LiveRoomsService sender) { var playerCount = sender.PlayerCount; RoomsButton.BoxText = playerCount.ToString(); - RoomsButton.BoxTip = t("hover.players_online.n", playerCount); + RoomsButton.BoxTip = t_legacy("hover.players_online.n", playerCount); UpdateFriendCount(); }