Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion samples/LocalizationSample.Blazor.Client/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public static async Task Main(string[] args)
builder.RootComponents.Add<App>("app");

builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddJsonLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddJsonLocalization(options => options.ResourcesPath = new[] { "Resources" });

await builder.Build().RunAsync();
}
Expand Down
2 changes: 1 addition & 1 deletion samples/LocalizationSample.Blazor.Server/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public Startup(IConfiguration configuration)
public void ConfigureServices(IServiceCollection services)
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr-FR");
services.AddJsonLocalization(options => options.ResourcesPath = "Resources");
services.AddJsonLocalization(options => options.ResourcesPath = new[] { "Resources" });
services.AddRazorPages();
services.AddServerSideBlazor();
services.AddSingleton<WeatherForecastService>();
Expand Down
2 changes: 1 addition & 1 deletion samples/LocalizationSample.Mvc/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class Startup
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
services.AddJsonLocalization(options => options.ResourcesPath = "Resources");
services.AddJsonLocalization(options => options.ResourcesPath = new[] { "Resources" });
services.AddMvc()
.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
.AddDataAnnotationsLocalization(options =>
Expand Down
2 changes: 1 addition & 1 deletion samples/LocalizationSample/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddJsonLocalization(options => options.ResourcesPath = "Resources");
services.AddJsonLocalization(options => options.ResourcesPath = new[] { "Resources" });
}

public void Configure(IApplicationBuilder app, IHostEnvironment env, IStringLocalizer localizer1, IStringLocalizer<Startup> localizer2)
Expand Down
93 changes: 73 additions & 20 deletions src/My.Extensions.Localization.Json/Internal/JsonResourceManager.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
Expand All @@ -8,22 +9,30 @@ namespace My.Extensions.Localization.Json.Internal;

public class JsonResourceManager
{
private readonly JsonFileWatcher _jsonFileWatcher;
private readonly List<JsonFileWatcher> _jsonFileWatchers = new();
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> _resourcesCache = new();
private readonly ConcurrentDictionary<string, HashSet<string>> _loadedFilesCache = new();


public JsonResourceManager(string resourcesPath, string resourceName = null)
public JsonResourceManager(string[] resourcesPaths, string resourceName = null)
{
ResourcesPath = resourcesPath;
ResourcesPaths = resourcesPaths ?? Array.Empty<string>();
ResourceName = resourceName;

_jsonFileWatcher = new(resourcesPath);
_jsonFileWatcher.Changed += RefreshResourcesCache;
foreach (var path in ResourcesPaths)
{
SetupFileWatcher(path);
}
}

public JsonResourceManager(string[] resourcesPaths)
: this(resourcesPaths, null)
{
}

public string ResourceName { get; }

public string ResourcesPath { get; }
public string[] ResourcesPaths { get; }

public string ResourcesFilePath { get; private set; }

Expand Down Expand Up @@ -111,68 +120,112 @@ public virtual string GetString(string name, CultureInfo culture)
}

private void TryLoadResourceSet(CultureInfo culture)
{
// Load from all resources paths (merging resources, first path takes precedence)
foreach (var path in ResourcesPaths)
{
TryLoadResourceSetFromPath(path, culture);
}
}

private void TryLoadResourceSetFromPath(string basePath, CultureInfo culture)
{
if (string.IsNullOrEmpty(ResourceName))
{
var file = Path.Combine(ResourcesPath, $"{culture.Name}.json");
var file = Path.Combine(basePath, $"{culture.Name}.json");

TryAddResources(file);
TryAddResources(file, culture);
}
else
{
var resourceFiles = Enumerable.Empty<string>();
var rootCulture = culture.Name[..2];
if (ResourceName.Contains('.'))
{
resourceFiles = Directory.EnumerateFiles(ResourcesPath, $"{ResourceName}.{rootCulture}*.json");
if (Directory.Exists(basePath))
{
resourceFiles = Directory.EnumerateFiles(basePath, $"{ResourceName}.{rootCulture}*.json");
}

if (!resourceFiles.Any())
{
resourceFiles = GetResourceFiles(rootCulture);
resourceFiles = GetResourceFiles(basePath, rootCulture);
}
}
else
{
resourceFiles = GetResourceFiles(rootCulture);
resourceFiles = GetResourceFiles(basePath, rootCulture);
}

foreach (var file in resourceFiles)
{
var fileName = Path.GetFileNameWithoutExtension(file);
var cultureName = fileName[(fileName.LastIndexOf('.') + 1)..];

culture = CultureInfo.GetCultureInfo(cultureName);
var fileCulture = CultureInfo.GetCultureInfo(cultureName);

TryAddResources(file);
TryAddResources(file, fileCulture);
}
}

IEnumerable<string> GetResourceFiles(string culture)
IEnumerable<string> GetResourceFiles(string baseResourcesPath, string cultureName)
{
var resourcePath = ResourceName.Replace('.', Path.AltDirectorySeparatorChar);
var resourcePathLastDirectorySeparatorIndex = resourcePath.LastIndexOf(Path.AltDirectorySeparatorChar);
var resourceName = resourcePath[(resourcePathLastDirectorySeparatorIndex + 1)..];
var resourcesPath = resourcePathLastDirectorySeparatorIndex == -1
? ResourcesPath
: Path.Combine(ResourcesPath, resourcePath[..resourcePathLastDirectorySeparatorIndex]);
? baseResourcesPath
: Path.Combine(baseResourcesPath, resourcePath[..resourcePathLastDirectorySeparatorIndex]);

return Directory.Exists(resourcesPath)
? Directory.EnumerateFiles(resourcesPath, $"{resourceName}.{culture}*.json")
? Directory.EnumerateFiles(resourcesPath, $"{resourceName}.{cultureName}*.json")
: [];
}

void TryAddResources(string resourceFile)
void TryAddResources(string resourceFile, CultureInfo resourceCulture)
{
var key = $"{ResourceName}.{culture.Name}";
var key = $"{ResourceName}.{resourceCulture.Name}";

// Track loaded files to avoid re-loading
var loadedFiles = _loadedFilesCache.GetOrAdd(key, _ => new HashSet<string>());
if (!loadedFiles.Add(resourceFile))
{
// File already loaded for this key, skip
return;
}

if (!_resourcesCache.ContainsKey(key))
{
var resources = JsonResourceLoader.Load(resourceFile);

_resourcesCache.TryAdd(key, new ConcurrentDictionary<string, string>(resources));
}
else
{
// Merge resources from additional paths (don't override existing keys)
var existingResources = _resourcesCache[key];
var additionalResources = JsonResourceLoader.Load(resourceFile);

foreach (var item in additionalResources)
{
existingResources.TryAdd(item.Key, item.Value);
}
}
}
}

private void SetupFileWatcher(string path)
{
if (!Directory.Exists(path))
{
return;
}

var watcher = new JsonFileWatcher(path);
watcher.Changed += RefreshResourcesCache;
_jsonFileWatchers.Add(watcher);
}

private void RefreshResourcesCache(object sender, FileSystemEventArgs e)
{
var key = Path.GetFileNameWithoutExtension(e.FullPath);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
using Microsoft.Extensions.Localization;
using System;
using Microsoft.Extensions.Localization;

namespace My.Extensions.Localization.Json;

public class JsonLocalizationOptions : LocalizationOptions
{
public ResourcesType ResourcesType { get; set; } = ResourcesType.TypeBased;

public new string[] ResourcesPath { get; set; } = Array.Empty<string>();
}
40 changes: 22 additions & 18 deletions src/My.Extensions.Localization.Json/JsonStringLocalizerFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
Expand All @@ -16,7 +17,7 @@ public class JsonStringLocalizerFactory : IStringLocalizerFactory
{
private readonly IResourceNamesCache _resourceNamesCache = new ResourceNamesCache();
private readonly ConcurrentDictionary<string, JsonStringLocalizer> _localizerCache = new();
private readonly string _resourcesRelativePath;
private readonly string[] _resourcesPaths;
private readonly ResourcesType _resourcesType = ResourcesType.TypeBased;
private readonly ILoggerFactory _loggerFactory;

Expand All @@ -26,7 +27,7 @@ public JsonStringLocalizerFactory(
{
ArgumentNullException.ThrowIfNull(localizationOptions);

_resourcesRelativePath = localizationOptions.Value.ResourcesPath ?? string.Empty;
_resourcesPaths = localizationOptions.Value.ResourcesPath ?? Array.Empty<string>();
_resourcesType = localizationOptions.Value.ResourcesType;
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
Expand All @@ -35,14 +36,12 @@ public IStringLocalizer Create(Type resourceSource)
{
ArgumentNullException.ThrowIfNull(resourceSource);

string resourcesPath = string.Empty;

// TODO: Check why an exception happen before the host build
if (resourceSource.Name == "Controller")
{
resourcesPath = Path.Combine(PathHelpers.GetApplicationRoot(), GetResourcePath(resourceSource.Assembly));
var resourcesPaths = GetResourcePaths(resourceSource.Assembly);

return _localizerCache.GetOrAdd(resourceSource.Name, _ => CreateJsonStringLocalizer(resourcesPath, TryFixInnerClassPath("Controller")));
return _localizerCache.GetOrAdd(resourceSource.Name, _ => CreateJsonStringLocalizer(resourcesPaths, TryFixInnerClassPath("Controller")));
}

var typeInfo = resourceSource.GetTypeInfo();
Expand All @@ -52,10 +51,10 @@ public IStringLocalizer Create(Type resourceSource)
? typeInfo.Name
: TrimPrefix(typeInfo.FullName, rootNamespace + ".");

resourcesPath = Path.Combine(PathHelpers.GetApplicationRoot(), GetResourcePath(assembly));
var paths = GetResourcePaths(assembly);
typeName = TryFixInnerClassPath(typeName);

return _localizerCache.GetOrAdd($"culture={CultureInfo.CurrentUICulture.Name}, typeName={typeName}", _ => CreateJsonStringLocalizer(resourcesPath, typeName));
return _localizerCache.GetOrAdd($"culture={CultureInfo.CurrentUICulture.Name}, typeName={typeName}", _ => CreateJsonStringLocalizer(paths, typeName));
}

public IStringLocalizer Create(string baseName, string location)
Expand All @@ -67,13 +66,13 @@ public IStringLocalizer Create(string baseName, string location)
{
var assemblyName = new AssemblyName(location);
var assembly = Assembly.Load(assemblyName);
var resourcesPath = Path.Combine(PathHelpers.GetApplicationRoot(), GetResourcePath(assembly));
var resourcesPaths = GetResourcePaths(assembly);
string resourceName = null;
if (baseName == string.Empty)
{
resourceName = baseName;

return CreateJsonStringLocalizer(resourcesPath, resourceName);
return CreateJsonStringLocalizer(resourcesPaths, resourceName);
}

if (_resourcesType == ResourcesType.TypeBased)
Expand All @@ -83,29 +82,34 @@ public IStringLocalizer Create(string baseName, string location)
resourceName = TrimPrefix(baseName, rootNamespace + ".");
}

return CreateJsonStringLocalizer(resourcesPath, resourceName);
return CreateJsonStringLocalizer(resourcesPaths, resourceName);
});
}

protected virtual JsonStringLocalizer CreateJsonStringLocalizer(
string resourcesPath,
string[] resourcesPaths,
string resourceName)
{
var resourceManager = _resourcesType == ResourcesType.TypeBased
? new JsonResourceManager(resourcesPath, resourceName)
: new JsonResourceManager(resourcesPath);
? new JsonResourceManager(resourcesPaths, resourceName)
: new JsonResourceManager(resourcesPaths);
var logger = _loggerFactory.CreateLogger<JsonStringLocalizer>();

return new JsonStringLocalizer(resourceManager, _resourceNamesCache, logger);
}

private string GetResourcePath(Assembly assembly)
private string[] GetResourcePaths(Assembly assembly)
{
var resourceLocationAttribute = assembly.GetCustomAttribute<ResourceLocationAttribute>();

if (resourceLocationAttribute != null)
{
return new[] { Path.Combine(PathHelpers.GetApplicationRoot(), resourceLocationAttribute.ResourceLocation) };
}

return resourceLocationAttribute == null
? _resourcesRelativePath
: resourceLocationAttribute.ResourceLocation;
return _resourcesPaths
.Select(p => Path.Combine(PathHelpers.GetApplicationRoot(), p))
.ToArray();
}

private static string GetRootNamespace(Assembly assembly)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class JsonResourceManagerBenchmark

static JsonResourceManagerBenchmark()
{
var resources = "Resources";
var resources = new[] { "Resources" };
_jsonResourceManager = new JsonResourceManager(resources, Path.Combine("fr-FR.json"));
_frenchCulture = CultureInfo.GetCultureInfo("fr-FR");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class JsonResourceManagerBenchmarks

static JsonResourceManagerBenchmarks()
{
_jsonResourceManager = new JsonResourceManager("Resources\\fr-FR.json");
_jsonResourceManager = new JsonResourceManager(new[] { "Resources\\fr-FR.json" });
_frenchCulture = CultureInfo.GetCultureInfo("fr-FR");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"AdditionalKey": "Clé supplémentaire",
"Welcome": "Bienvenue"
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public void AddJsonLocalizationWithOptions()

// Act
JsonLocalizationServiceCollectionExtensions.AddJsonLocalization(services,
options => options.ResourcesPath = "Resources");
options => options.ResourcesPath = new[] { "Resources" });

var localizationConfigureOptions = (ConfigureNamedOptions<JsonLocalizationOptions>)services
.SingleOrDefault(sd => sd.ServiceType == typeof(IConfigureOptions<JsonLocalizationOptions>))
Expand All @@ -44,6 +44,6 @@ public void AddJsonLocalizationWithOptions()

localizationConfigureOptions.Action.Invoke(localizationOptions);

Assert.Equal("Resources", localizationOptions.ResourcesPath);
Assert.Equal(new[] { "Resources" }, localizationOptions.ResourcesPath);
}
}
Loading
Loading