diff --git a/src/My.Extensions.Localization.Json/Internal/JsonResourceManager.cs b/src/My.Extensions.Localization.Json/Internal/JsonResourceManager.cs index 636ec10..a33e6de 100644 --- a/src/My.Extensions.Localization.Json/Internal/JsonResourceManager.cs +++ b/src/My.Extensions.Localization.Json/Internal/JsonResourceManager.cs @@ -9,15 +9,20 @@ namespace My.Extensions.Localization.Json.Internal; public class JsonResourceManager { - private readonly List _jsonFileWatchers = new(); + private readonly List _jsonFileWatchers = []; private readonly ConcurrentDictionary> _resourcesCache = new(); private readonly ConcurrentDictionary> _loadedFilesCache = new(); + public JsonResourceManager(string resourcesPath, string resourceName = null) + : this([resourcesPath], fallBackToParentUICultures: true, resourceName) + { + } - public JsonResourceManager(string[] resourcesPaths, string resourceName = null) + public JsonResourceManager(string[] resourcesPaths, bool fallBackToParentUICultures, string resourceName = null) { ResourcesPaths = resourcesPaths ?? Array.Empty(); ResourceName = resourceName; + FallBackToParentUICultures = fallBackToParentUICultures; foreach (var path in ResourcesPaths) { @@ -25,8 +30,8 @@ public JsonResourceManager(string[] resourcesPaths, string resourceName = null) } } - public JsonResourceManager(string[] resourcesPaths) - : this(resourcesPaths, null) + public JsonResourceManager(string[] resourcesPaths, string resourceName = null) + : this(resourcesPaths, fallBackToParentUICultures: true, resourceName) { } @@ -36,6 +41,12 @@ public JsonResourceManager(string[] resourcesPaths) public string ResourcesFilePath { get; private set; } + /// + /// Gets a value indicating whether to fall back to parent UI cultures + /// when a localized string is not found for the current culture. + /// + public bool FallBackToParentUICultures { get; } + public virtual ConcurrentDictionary GetResourceSet(CultureInfo culture, bool tryParents) { TryLoadResourceSet(culture); @@ -75,7 +86,7 @@ public virtual ConcurrentDictionary GetResourceSet(CultureInfo c public virtual string GetString(string name) { var culture = CultureInfo.CurrentUICulture; - GetResourceSet(culture, tryParents: true); + GetResourceSet(culture, tryParents: FallBackToParentUICultures); if (_resourcesCache.IsEmpty) { @@ -93,6 +104,11 @@ public virtual string GetString(string name) } } + if (!FallBackToParentUICultures) + { + break; + } + culture = culture.Parent; } while (culture != culture.Parent); @@ -101,22 +117,34 @@ public virtual string GetString(string name) public virtual string GetString(string name, CultureInfo culture) { - GetResourceSet(culture, tryParents: true); + GetResourceSet(culture, tryParents: FallBackToParentUICultures); if (_resourcesCache.IsEmpty) { return null; } - var key = $"{ResourceName}.{culture.Name}"; - if (!_resourcesCache.TryGetValue(key, out var resources)) + var currentCulture = culture; + do { - return null; - } + var key = $"{ResourceName}.{currentCulture.Name}"; + if (_resourcesCache.TryGetValue(key, out var resources)) + { + if (resources.TryGetValue(name, out var value)) + { + return value.ToString(); + } + } + + if (!FallBackToParentUICultures) + { + break; + } + + currentCulture = currentCulture.Parent; + } while (currentCulture != currentCulture.Parent); - return resources.TryGetValue(name, out var value) - ? value.ToString() - : null; + return null; } private void TryLoadResourceSet(CultureInfo culture) diff --git a/src/My.Extensions.Localization.Json/JsonLocalizationOptions.cs b/src/My.Extensions.Localization.Json/JsonLocalizationOptions.cs index b30d68a..df578a3 100644 --- a/src/My.Extensions.Localization.Json/JsonLocalizationOptions.cs +++ b/src/My.Extensions.Localization.Json/JsonLocalizationOptions.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Localization; namespace My.Extensions.Localization.Json; @@ -7,5 +6,5 @@ public class JsonLocalizationOptions : LocalizationOptions { public ResourcesType ResourcesType { get; set; } = ResourcesType.TypeBased; - public new string[] ResourcesPath { get; set; } = Array.Empty(); + public new string[] ResourcesPath { get; set; } = []; } \ No newline at end of file diff --git a/src/My.Extensions.Localization.Json/JsonStringLocalizerFactory.cs b/src/My.Extensions.Localization.Json/JsonStringLocalizerFactory.cs index bf0c58a..a63e550 100644 --- a/src/My.Extensions.Localization.Json/JsonStringLocalizerFactory.cs +++ b/src/My.Extensions.Localization.Json/JsonStringLocalizerFactory.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Reflection; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -19,16 +20,26 @@ public class JsonStringLocalizerFactory : IStringLocalizerFactory private readonly ConcurrentDictionary _localizerCache = new(); private readonly string[] _resourcesPaths; private readonly ResourcesType _resourcesType = ResourcesType.TypeBased; + private readonly bool _fallBackToParentUICultures = true; private readonly ILoggerFactory _loggerFactory; public JsonStringLocalizerFactory( IOptions localizationOptions, ILoggerFactory loggerFactory) + : this(localizationOptions, loggerFactory, null) + { + } + + public JsonStringLocalizerFactory( + IOptions localizationOptions, + ILoggerFactory loggerFactory, + IOptions requestLocalizationOptions) { ArgumentNullException.ThrowIfNull(localizationOptions); - _resourcesPaths = localizationOptions.Value.ResourcesPath ?? Array.Empty(); + _resourcesPaths = localizationOptions.Value.ResourcesPath ?? []; _resourcesType = localizationOptions.Value.ResourcesType; + _fallBackToParentUICultures = requestLocalizationOptions?.Value?.FallBackToParentUICultures ?? true; _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); } @@ -91,8 +102,8 @@ protected virtual JsonStringLocalizer CreateJsonStringLocalizer( string resourceName) { var resourceManager = _resourcesType == ResourcesType.TypeBased - ? new JsonResourceManager(resourcesPaths, resourceName) - : new JsonResourceManager(resourcesPaths); + ? new JsonResourceManager(resourcesPaths, _fallBackToParentUICultures, resourceName) + : new JsonResourceManager(resourcesPaths, _fallBackToParentUICultures, null); var logger = _loggerFactory.CreateLogger(); return new JsonStringLocalizer(resourceManager, _resourceNamesCache, logger); @@ -104,12 +115,10 @@ private string[] GetResourcePaths(Assembly assembly) if (resourceLocationAttribute != null) { - return new[] { Path.Combine(PathHelpers.GetApplicationRoot(), resourceLocationAttribute.ResourceLocation) }; + return [Path.Combine(PathHelpers.GetApplicationRoot(), resourceLocationAttribute.ResourceLocation)]; } - return _resourcesPaths - .Select(p => Path.Combine(PathHelpers.GetApplicationRoot(), p)) - .ToArray(); + return [.. _resourcesPaths.Select(p => Path.Combine(PathHelpers.GetApplicationRoot(), p))]; } private static string GetRootNamespace(Assembly assembly) diff --git a/src/My.Extensions.Localization.Json/My.Extensions.Localization.Json.csproj b/src/My.Extensions.Localization.Json/My.Extensions.Localization.Json.csproj index 98f5b92..0290c62 100644 --- a/src/My.Extensions.Localization.Json/My.Extensions.Localization.Json.csproj +++ b/src/My.Extensions.Localization.Json/My.Extensions.Localization.Json.csproj @@ -21,6 +21,7 @@ + diff --git a/test/My.Extensions.Localization.Json.Tests/JsonStringLocalizerTests.cs b/test/My.Extensions.Localization.Json.Tests/JsonStringLocalizerTests.cs index 801d069..dc070b7 100644 --- a/test/My.Extensions.Localization.Json.Tests/JsonStringLocalizerTests.cs +++ b/test/My.Extensions.Localization.Json.Tests/JsonStringLocalizerTests.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -10,6 +7,8 @@ using Microsoft.Extensions.Options; using Moq; using My.Extensions.Localization.Json.Tests.Common; +using System.Linq; +using System.Threading.Tasks; using Xunit; namespace My.Extensions.Localization.Json.Tests; @@ -22,7 +21,7 @@ public JsonStringLocalizerTests() { var _localizationOptions = new Mock>(); _localizationOptions.Setup(o => o.Value) - .Returns(() => new JsonLocalizationOptions { ResourcesPath = new[] { "Resources" } }); + .Returns(() => new JsonLocalizationOptions { ResourcesPath = ["Resources"] }); var localizerFactory = new JsonStringLocalizerFactory(_localizationOptions.Object, NullLoggerFactory.Instance); var location = "My.Extensions.Localization.Json.Tests"; var basename = $"{location}.Common.{nameof(Test)}"; @@ -135,7 +134,7 @@ public async void CultureBasedResourcesUsesIStringLocalizer() { services.AddJsonLocalization(options => { - options.ResourcesPath = new[] { "Resources" }; + options.ResourcesPath = ["Resources"]; options.ResourcesType = ResourcesType.CultureBased; }); }) @@ -177,6 +176,83 @@ public void GetTranslationUsingKeyHeirarchy(string culture, string name, string Assert.Equal(expected, translation); } + [Fact] + public void GetTranslation_WithFallBackToParentUICulturesDisabled_DoesNotFallbackToParentCulture() + { + // Arrange + var localizationOptions = new Mock>(); + localizationOptions.Setup(o => o.Value) + .Returns(() => new JsonLocalizationOptions + { + ResourcesPath = ["Resources"] + }); + + var requestLocalizationOptions = new Mock>(); + requestLocalizationOptions.Setup(o => o.Value) + .Returns(() => new RequestLocalizationOptions + { + FallBackToParentUICultures = false + }); + + var localizerFactory = new JsonStringLocalizerFactory( + localizationOptions.Object, + NullLoggerFactory.Instance, + requestLocalizationOptions.Object); + var location = "My.Extensions.Localization.Json.Tests"; + var basename = $"{location}.Common.{nameof(Test)}"; + var localizer = localizerFactory.Create(basename, location); + + LocalizationHelper.SetCurrentCulture("fr-FR"); + + // Act + // "Yes" exists only in Test.fr.json (parent culture), not in Test.fr-FR.json + var translation = localizer["Yes"]; + + // Assert + // When FallBackToParentUICultures is disabled, it should not find "Yes" in fr-FR + // and return the key itself as the translation was not found + Assert.Equal("Yes", translation.Value); + Assert.True(translation.ResourceNotFound); + } + + [Fact] + public void GetTranslation_WithFallBackToParentUICulturesEnabled_FallsBackToParentCulture() + { + // Arrange + var localizationOptions = new Mock>(); + localizationOptions.Setup(o => o.Value) + .Returns(() => new JsonLocalizationOptions + { + ResourcesPath = ["Resources"] + }); + + var requestLocalizationOptions = new Mock>(); + requestLocalizationOptions.Setup(o => o.Value) + .Returns(() => new RequestLocalizationOptions + { + FallBackToParentUICultures = true + }); + + var localizerFactory = new JsonStringLocalizerFactory( + localizationOptions.Object, + NullLoggerFactory.Instance, + requestLocalizationOptions.Object); + var location = "My.Extensions.Localization.Json.Tests"; + var basename = $"{location}.Common.{nameof(Test)}"; + var localizer = localizerFactory.Create(basename, location); + + LocalizationHelper.SetCurrentCulture("fr-FR"); + + // Act + // "Yes" exists only in Test.fr.json (parent culture), not in Test.fr-FR.json + var translation = localizer["Yes"]; + + // Assert + // When FallBackToParentUICultures is enabled (default), it should find "Yes" in fr (parent) + Assert.Equal("Oui", translation.Value); + Assert.False(translation.ResourceNotFound); + } + private class SharedResource { public string Hello { get; set; }