Browse Source

Merge pull request #25227 from antonGritsenko/dev

pull/25243/head
Ma Liming 6 months ago
committed by GitHub
parent
commit
b1f8cb4b23
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 37
      docs/en/framework/fundamentals/localization.md
  2. 13
      framework/src/Volo.Abp.Localization/Volo/Abp/Localization/Json/JsonLocalizationDictionaryBuilder.cs
  3. 26
      framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs
  4. 4
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalizationTestModule.cs
  5. 29
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs
  6. 51
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs
  7. 77
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs
  8. 6
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/!en_First.json
  9. 7
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Base.json
  10. 7
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Book.json
  11. 6
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/zen_Last.json
  12. 7
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de.json
  13. 10
      framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de_Book.json

37
docs/en/framework/fundamentals/localization.md

@ -126,6 +126,43 @@ var str2 = L["Hi__0"]; // Bye World!
var str3 = L["Hi__1"]; // Hello World!
````
You can have more than one localization file with the same culture: files will be merged. This is useful for large modules where splitting translations by feature keeps each file manageable.
**Example file structure:**
```
Localization/
└── MyResource/
├── en.json ← base / shared strings
├── en_Authors.json ← Author feature strings
├── en_Books.json ← Book feature strings
└── en_Users.json ← User feature strings
```
Files are sorted by name (ordinal order) before merging, so the effective merge order is `en.json` → `en_Authors.json` → `en_Books.json` → `en_Users.json`.
```
en.json en_Authors.json en_Books.json
┌─────────────────┐ ┌───────────────┐ ┌──────────────────┐
│ DisplayName=Name│ │ Author.Id=Id │ │ Book.Id=ISBN │
│ SaveButton=Save │ │ Author.Bio=.. │ │ Book.Title=Title │
└─────────────────┘ └───────────────┘ └──────────────────┘
│ │ │
└──────────────────┴──────────────────┘
│ merge (later file wins on duplicate keys)
▼
┌────────────────────┐
│ DisplayName = Name │
│ SaveButton = Save │
│ Author.Id = Id │
│ Author.Bio = ... │
│ Book.Id = ISBN │
│ Book.Title = Title│
└────────────────────┘
```
> Note: If the same key is defined in multiple files, the value from the last file (in sort order) wins.
### Default Resource
`AbpLocalizationOptions.DefaultResourceType` can be set to a resource type, so it is used when the localization resource was not specified:

13
framework/src/Volo.Abp.Localization/Volo/Abp/Localization/Json/JsonLocalizationDictionaryBuilder.cs

@ -60,7 +60,6 @@ public static class JsonLocalizationDictionaryBuilder
}
var dictionary = new Dictionary<string, LocalizedString>();
var dublicateNames = new List<string>();
foreach (var item in FlattenTexts(jsonFile.Texts))
{
@ -68,20 +67,10 @@ public static class JsonLocalizationDictionaryBuilder
{
throw new AbpException("The key is empty in given json string.");
}
if (dictionary.GetOrDefault(item.Key) != null)
{
dublicateNames.Add(item.Key);
}
dictionary[item.Key] = new LocalizedString(item.Key, item.Value.NormalizeLineEndings());
}
if (dublicateNames.Count > 0)
{
throw new AbpException(
"A dictionary can not contain same key twice. There are some duplicated names: " +
dublicateNames.JoinAsString(", "));
}
return new StaticLocalizationDictionary(cultureCode, dictionary);
}

26
framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
@ -19,7 +20,6 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza
private Dictionary<string, ILocalizationDictionary>? _dictionaries;
private bool _subscribedForChanges;
private readonly object _syncObj = new object();
private LocalizationResourceBase _resource = default!;
protected VirtualFileLocalizationResourceContributorBase(string virtualPath)
{
@ -28,7 +28,6 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza
public virtual void Initialize(LocalizationResourceInitializationContext context)
{
_resource = context.Resource;
_virtualFileProvider = context.ServiceProvider.GetRequiredService<IVirtualFileProvider>();
}
@ -88,15 +87,12 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza
private Dictionary<string, ILocalizationDictionary> CreateDictionaries()
{
var dictionaries = new Dictionary<string, ILocalizationDictionary>();
var rawDictionaries = new Dictionary<string, Dictionary<string, LocalizedString>>();
foreach (var file in _virtualFileProvider.GetDirectoryContents(_virtualPath))
foreach (var file in _virtualFileProvider.GetDirectoryContents(_virtualPath)
.Where(f => !f.IsDirectory && CanParseFile(f))
.OrderBy(f => f.Name, StringComparer.Ordinal))
{
if (file.IsDirectory || !CanParseFile(file))
{
continue;
}
var dictionary = CreateDictionaryFromFile(file);
if (dictionary == null)
@ -104,15 +100,19 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza
continue;
}
if (dictionaries.ContainsKey(dictionary.CultureName))
if (!rawDictionaries.TryGetValue(dictionary.CultureName, out var raw))
{
throw new AbpException($"{file.GetVirtualOrPhysicalPathOrNull()} dictionary has a culture name '{dictionary.CultureName}' which is already defined! Localization resource: {_resource.ResourceName}");
raw = new Dictionary<string, LocalizedString>();
rawDictionaries[dictionary.CultureName] = raw;
}
dictionaries[dictionary.CultureName] = dictionary;
dictionary.Fill(raw);
}
return dictionaries;
return rawDictionaries.ToDictionary(
kvp => kvp.Key,
kvp => (ILocalizationDictionary)new StaticLocalizationDictionary(kvp.Key, kvp.Value)
);
}
protected abstract bool CanParseFile(IFileInfo file);

4
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalizationTestModule.cs

@ -29,6 +29,10 @@ public class AbpLocalizationTestModule : AbpModule
.Add("LocalizationTestCountryNames")
.AddVirtualJson("/Volo/Abp/Localization/TestResources/Base/CountryNames");
options.Resources
.Add("LocalizationTestFilesSplit")
.AddVirtualJson("/Volo/Abp/Localization/TestResources/FilesSplit");
options.Resources
.Add<LocalizationTestResource>("en")
.AddVirtualJson("/Volo/Abp/Localization/TestResources/Source")

29
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs

@ -234,6 +234,35 @@ public class AbpLocalization_Tests : AbpIntegratedTest<AbpLocalizationTestModule
}
}
[Fact]
public void Should_Get_Localized_Text_If_Defined_In_Requested_Culture_Defined_In_Different_Files()
{
using (CultureHelper.Use(CultureInfo.GetCultureInfo("de")))
{
_localizer["Car"].Value.ShouldBe("Auto");
_localizer["CarPlural"].Value.ShouldBe("Autos");
_localizer["Biography"].Value.ShouldBe("Biografie");
_localizer["Enum:BookType.Undefined"].Value.ShouldBe("Nicht definiert");
}
}
[Fact]
public void GetAllStrings_Should_Contain_Values_From_All_Split_Files()
{
using (CultureHelper.Use(CultureInfo.GetCultureInfo("de")))
{
var allStrings = _localizer.GetAllStrings(false).ToList();
// From de.json
allStrings.ShouldContain(ls => ls.Name == "Car" && ls.Value == "Auto");
allStrings.ShouldContain(ls => ls.Name == "FortyTwo" && ls.Value == "Zweiundvierzig");
// From de_Book.json
allStrings.ShouldContain(ls => ls.Name == "Biography" && ls.Value == "Biografie");
allStrings.ShouldContain(ls => ls.Name == "Enum:BookType.Undefined" && ls.Value == "Nicht definiert");
}
}
[Fact]
public void GetAllStrings_With_Parents()
{

51
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Localization;
using Shouldly;
using Volo.Abp.DynamicProxy;
@ -90,4 +91,50 @@ public class AbpStringLocalizerFactory_Tests : AbpIntegratedTest<AbpLocalization
localizer2["CarPlural"].Value.ShouldBe("CarPlural");
}
}
}
[Fact]
public void Should_Create_Resource_By_Name_From_Split_Files()
{
using (CultureHelper.Use("en"))
{
var localizer = _factory.CreateByResourceNameOrNull("LocalizationTestFilesSplit");
localizer.ShouldNotBeNull();
localizer["Base.Id"].Value.ShouldBe("Id");
localizer["Book.Id"].Value.ShouldBe("ISBN");
localizer["ThisIsRequiredValue"].Value.ShouldBe("This is required value (this will be used)");
}
}
[Fact]
public void Should_Get_All_Strings_From_Split_Files()
{
using (CultureHelper.Use("en"))
{
var localizer = _factory.CreateByResourceNameOrNull("LocalizationTestFilesSplit");
localizer.ShouldNotBeNull();
var allStrings = localizer.GetAllStrings(includeParentCultures: false).ToList();
allStrings.ShouldContain(ls => ls.Name == "Base.Id" && ls.Value == "Id");
allStrings.ShouldContain(ls => ls.Name == "Base.Name" && ls.Value == "Name");
allStrings.ShouldContain(ls => ls.Name == "Book.Id" && ls.Value == "ISBN");
allStrings.ShouldContain(ls => ls.Name == "Book.Name" && ls.Value == "Title");
allStrings.ShouldContain(ls => ls.Name == "ThisIsRequiredValue" && ls.Value == "This is required value (this will be used)");
}
}
[Fact]
public void Should_Override_Value_From_Later_Split_File()
{
using (CultureHelper.Use("en"))
{
var localizer = _factory.CreateByResourceNameOrNull("LocalizationTestFilesSplit");
localizer.ShouldNotBeNull();
// !en_First.json defines "This is required value"
// zen_Last.json defines "This is required value (this will be used)"
// zen_Last.json sorts after !en_First.json (ordinal: '!' < 'z'), so its value wins
localizer["ThisIsRequiredValue"].Value.ShouldBe("This is required value (this will be used)");
}
}
}

77
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs

@ -0,0 +1,77 @@
using Shouldly;
using Volo.Abp.Localization.Json;
using Xunit;
namespace Volo.Abp.Localization;
/// <summary>
/// Testing edge cases for <see cref="JsonLocalizationDictionaryBuilder"/>
/// </summary>
public class JsonLocalizationDictionaryBuilder_Tests
{
[Fact]
public void Should_Use_Last_Value_When_Json_Contains_Duplicate_Keys()
{
// This test locks the behavior of System.Text.Json when deserializing duplicate JSON property names.
// If STJ changes this behavior in a future version, this test will catch the regression.
var input = """
{
"culture": "en",
"texts": {
"ThisFieldIsRequired": "This field is required",
"MaxLengthErrorMessage": "This field can be maximum of '{0}' chars",
"ThisFieldIsRequired": "This field is required again"
}
}
""";
var localizationDictionary = JsonLocalizationDictionaryBuilder.BuildFromJsonString(input);
localizationDictionary.ShouldNotBeNull();
var localizationString = localizationDictionary.GetOrNull("ThisFieldIsRequired");
localizationString.ShouldNotBeNull();
localizationString.Value.ShouldBe("This field is required again");
}
[Fact]
public void Should_Use_Nested_Value_When_Flat_Key_Is_Defined_Before_Nested_Object()
{
// When a flat key (e.g. "Foo__Bar") appears before a nested object (e.g. "Foo": {"Bar": ...}),
// the nested value wins because FlattenTexts processes keys in order and last-write wins.
var input = """
{
"culture": "en",
"texts": {
"DeepLocalizationKey__DeepKey": "FlatValue",
"DeepLocalizationKey": { "DeepKey": "NestedValue" }
}
}
""";
var localizationDictionary = JsonLocalizationDictionaryBuilder.BuildFromJsonString(input);
localizationDictionary.ShouldNotBeNull();
var localizationString = localizationDictionary.GetOrNull("DeepLocalizationKey__DeepKey");
localizationString.ShouldNotBeNull();
localizationString.Value.ShouldBe("NestedValue");
}
[Fact]
public void JsonLocalizationDictionaryBuilder_Should_Handle_Deep_Duplicates()
{
var input = """
{
"culture": "en",
"texts": {
"ThisFieldIsRequired": "This field is required",
"DeepLocalizationKey": { "DeepKey": "DeepValue" },
"DeepLocalizationKey__DeepKey": "Another translation"
}
}
""";
var localizationDictionary = JsonLocalizationDictionaryBuilder.BuildFromJsonString(input);
localizationDictionary.ShouldNotBeNull();
var localizationString = localizationDictionary.GetOrNull("DeepLocalizationKey__DeepKey");
localizationString.ShouldNotBeNull();
localizationString.Value.ShouldBe("Another translation");
}
}

6
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/!en_First.json

@ -0,0 +1,6 @@
{
"culture": "en",
"texts": {
"ThisIsRequiredValue": "This is required value"
}
}

7
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Base.json

@ -0,0 +1,7 @@
{
"culture": "en",
"texts": {
"Base.Id": "Id",
"Base.Name": "Name"
}
}

7
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Book.json

@ -0,0 +1,7 @@
{
"culture": "en",
"texts": {
"Book.Id": "ISBN",
"Book.Name": "Title"
}
}

6
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/zen_Last.json

@ -0,0 +1,6 @@
{
"culture": "en",
"texts": {
"ThisIsRequiredValue": "This is required value (this will be used)"
}
}

7
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de.json

@ -6,11 +6,6 @@
"CarPlural": "Autos",
"MaxLenghtErrorMessage": "Die Länge dieses Feldes kann maximal '{0}'-Zeichen betragen",
"Universe": "Universum",
"FortyTwo": "Zweiundvierzig",
"Enum:BookType.Undefined": "Nicht definiert",
"Enum:BookType.0": "Undefiniert mit Wert 0",
"BookType.Adventure": "Abenteuer",
"BookType.1": "Abenteuer mit Wert 1",
"Biography": "Biografie"
"FortyTwo": "Zweiundvierzig"
}
}

10
framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de_Book.json

@ -0,0 +1,10 @@
{
"culture": "de",
"texts": {
"Enum:BookType.Undefined": "Nicht definiert",
"Enum:BookType.0": "Undefiniert mit Wert 0",
"BookType.Adventure": "Abenteuer",
"BookType.1": "Abenteuer mit Wert 1",
"Biography": "Biografie"
}
}
Loading…
Cancel
Save