From 67f523e454ecb4027be90899f8597f1916a2c75e Mon Sep 17 00:00:00 2001 From: Anton Gritsenko Date: Thu, 9 Apr 2026 11:59:18 +0300 Subject: [PATCH 1/7] Add support for multiple localization files with same culture --- .../Localization/ILocalizationDictionary.cs | 2 + .../Json/JsonLocalizationDictionaryBuilder.cs | 13 +---- .../StaticLocalizationDictionary.cs | 19 ++++++++ ...FileLocalizationResourceContributorBase.cs | 10 ++-- .../Localization/AbpLocalizationTestModule.cs | 4 ++ .../Abp/Localization/AbpLocalization_Tests.cs | 21 +++++++++ .../AbpStringLocalizerFactory_Tests.cs | 13 +++++ ...JsonLocalizationDictionaryBuilder_Tests.cs | 47 +++++++++++++++++++ .../TestResources/FilesSplit/!en_First.json | 6 +++ .../TestResources/FilesSplit/en_Base.json | 7 +++ .../TestResources/FilesSplit/en_Book.json | 7 +++ .../TestResources/FilesSplit/zen_Last.json | 6 +++ .../Localization/TestResources/Source/de.json | 7 +-- .../TestResources/Source/de_Book.json | 10 ++++ 14 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs create mode 100644 framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/!en_First.json create mode 100644 framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Base.json create mode 100644 framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Book.json create mode 100644 framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/zen_Last.json create mode 100644 framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de_Book.json diff --git a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/ILocalizationDictionary.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/ILocalizationDictionary.cs index e6879c0007..359ef6c553 100644 --- a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/ILocalizationDictionary.cs +++ b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/ILocalizationDictionary.cs @@ -13,4 +13,6 @@ public interface ILocalizationDictionary LocalizedString? GetOrNull(string name); void Fill(Dictionary dictionary); + + void Merge(ILocalizationDictionary dictionary); } diff --git a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/Json/JsonLocalizationDictionaryBuilder.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/Json/JsonLocalizationDictionaryBuilder.cs index f065f0e695..d39c06a1b4 100644 --- a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/Json/JsonLocalizationDictionaryBuilder.cs +++ b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/Json/JsonLocalizationDictionaryBuilder.cs @@ -60,7 +60,6 @@ public static class JsonLocalizationDictionaryBuilder } var dictionary = new Dictionary(); - var dublicateNames = new List(); 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); } diff --git a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/StaticLocalizationDictionary.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/StaticLocalizationDictionary.cs index b363076fe0..213627e1c6 100644 --- a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/StaticLocalizationDictionary.cs +++ b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/StaticLocalizationDictionary.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Microsoft.Extensions.Localization; @@ -37,4 +38,22 @@ public class StaticLocalizationDictionary : ILocalizationDictionary dictionary[item.Key] = item.Value; } } + + public void Merge(ILocalizationDictionary dictionary) + { + if (dictionary is not StaticLocalizationDictionary staticLocalizationDictionary) + { + return; // do nothing, we have no idea what to do with such + } + + foreach (var item in staticLocalizationDictionary.Dictionary) + { + if (string.IsNullOrEmpty(item.Key)) + { + throw new AbpException("The key is empty in given json string."); + } + + Dictionary[item.Key] = item.Value; + } + } } diff --git a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs index d151dc78df..0c3b26aa60 100644 --- a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs +++ b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs @@ -104,12 +104,14 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza continue; } - if (dictionaries.ContainsKey(dictionary.CultureName)) + if (!dictionaries.ContainsKey(dictionary.CultureName)) { - throw new AbpException($"{file.GetVirtualOrPhysicalPathOrNull()} dictionary has a culture name '{dictionary.CultureName}' which is already defined! Localization resource: {_resource.ResourceName}"); + dictionaries[dictionary.CultureName] = dictionary; + } + else + { + dictionaries[dictionary.CultureName].Merge(dictionary); } - - dictionaries[dictionary.CultureName] = dictionary; } return dictionaries; diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalizationTestModule.cs b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalizationTestModule.cs index 32835cb8f4..d4ef509fd5 100644 --- a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalizationTestModule.cs +++ b/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("en") .AddVirtualJson("/Volo/Abp/Localization/TestResources/Source") diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs index 189c1b8614..424cb8af34 100644 --- a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs +++ b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs @@ -234,6 +234,27 @@ public class AbpLocalization_Tests : AbpIntegratedTest +/// Testing edge cases for +/// +public class JsonLocalizationDictionaryBuilder_Tests : AbpIntegratedTest +{ + [Fact] + public void JsonLocalizationDictionaryBuilder_Should_Handle_Duplicates() + { + var localizationDictionary = JsonLocalizationDictionaryBuilder + .BuildFromJsonString("{\r\n \"culture\": \"en\",\r\n \"texts\": {\r\n \"ThisFieldIsRequired\": \"This field is required\",\r\n \"MaxLenghtErrorMessage\": \"This field can be maximum of '{0}' chars\",\r\n \"Enum:BookType.Undefined\": \"Undefined from ValidationResource\",\r\n \"Enum:BookType.0\": \"Undefined with value 0 from ValidationResource\",\r\n \"BookType.Adventure\": \"Adventure from ValidationResource\",\r\n \"BookType.1\": \"Adventure with value 1 from ValidationResource\",\r\n \"Biography\": \"Biography from ValidationResource\",\r\n \"ThisFieldIsRequired\": \"This field is required again\"\r\n }\r\n}"); + + var localizationString = localizationDictionary.GetOrNull("ThisFieldIsRequired"); + localizationString.ShouldNotBeNull(); + + localizationString.Value.ShouldBe("This field is required again"); + } + + [Fact] + public void JsonLocalizationDictionaryBuilder_Should_Handle_Deep_Duplicates() + { + var input = @"{ +""culture"": ""en"", +""texts"": { + ""ThisFieldIsRequired"": ""This field is required"", + ""DeepLocaliaztionKey"": {""DeepKey"": ""DeepValue""}, + ""DeepLocaliaztionKey__DeepKey"": ""Another translation"" +} +}"; + + var localizationDictionary = JsonLocalizationDictionaryBuilder.BuildFromJsonString(input); + localizationDictionary.ShouldNotBeNull(); + var localizationString = localizationDictionary.GetOrNull("DeepLocaliaztionKey__DeepKey"); + localizationString.ShouldNotBeNull(); + localizationString.Value.ShouldBe("Another translation"); + } +} diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/!en_First.json b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/!en_First.json new file mode 100644 index 0000000000..681c77b5d5 --- /dev/null +++ b/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" + } +} diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Base.json b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Base.json new file mode 100644 index 0000000000..1bcfead01b --- /dev/null +++ b/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" + } +} diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Book.json b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/en_Book.json new file mode 100644 index 0000000000..22d6039843 --- /dev/null +++ b/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" + } +} diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/zen_Last.json b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/FilesSplit/zen_Last.json new file mode 100644 index 0000000000..a94dc23006 --- /dev/null +++ b/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)" + } +} diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de.json b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de.json index ee396f1341..4ef1655764 100644 --- a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de.json +++ b/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" } } \ No newline at end of file diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de_Book.json b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/TestResources/Source/de_Book.json new file mode 100644 index 0000000000..1847c4e4fb --- /dev/null +++ b/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" + } +} \ No newline at end of file From 9c76057ce0a3cfe447b124c2dbdc7b9248a83d2f Mon Sep 17 00:00:00 2001 From: Anton Gritsenko Date: Thu, 9 Apr 2026 12:20:32 +0300 Subject: [PATCH 2/7] Add documentation about localizatoin files split --- docs/en/framework/fundamentals/localization.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/framework/fundamentals/localization.md b/docs/en/framework/fundamentals/localization.md index 562446a892..97fafbb28c 100644 --- a/docs/en/framework/fundamentals/localization.md +++ b/docs/en/framework/fundamentals/localization.md @@ -126,6 +126,10 @@ 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. + +> Note: if you have the same key with the same culture then last value will win. This is true in case of file-per-culture and several files per culture. Files are ordered alphabetically, so if a same key defined in files `!en.json` and `z_en.json` then value from file `z_en.json` will be used. + ### Default Resource `AbpLocalizationOptions.DefaultResourceType` can be set to a resource type, so it is used when the localization resource was not specified: From f7c22b41239ad74b9c5c748553f4084e47476ee3 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 9 Apr 2026 18:04:42 +0800 Subject: [PATCH 3/7] Refactor localization handling and improve test coverage for split localization files --- .../en/framework/fundamentals/localization.md | 2 +- .../Localization/ILocalizationDictionary.cs | 2 - .../StaticLocalizationDictionary.cs | 19 -------- ...FileLocalizationResourceContributorBase.cs | 15 +++--- .../Abp/Localization/AbpLocalization_Tests.cs | 22 ++++++--- .../AbpStringLocalizerFactory_Tests.cs | 40 ++++++++++++++-- ...JsonLocalizationDictionaryBuilder_Tests.cs | 47 ++++++++++++------- 7 files changed, 90 insertions(+), 57 deletions(-) diff --git a/docs/en/framework/fundamentals/localization.md b/docs/en/framework/fundamentals/localization.md index 97fafbb28c..84366197e2 100644 --- a/docs/en/framework/fundamentals/localization.md +++ b/docs/en/framework/fundamentals/localization.md @@ -128,7 +128,7 @@ var str3 = L["Hi__1"]; // Hello World! You can have more than one localization file with the same culture: files will be merged. -> Note: if you have the same key with the same culture then last value will win. This is true in case of file-per-culture and several files per culture. Files are ordered alphabetically, so if a same key defined in files `!en.json` and `z_en.json` then value from file `z_en.json` will be used. +> Note: If the same key is defined in multiple files for the same culture, the last value wins. Files are sorted by name using ordinal (byte-order) string comparison before merging, so if `!en.json` and `zen_Book.json` both define the same key, the value from `zen_Book.json` is used. ### Default Resource diff --git a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/ILocalizationDictionary.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/ILocalizationDictionary.cs index 359ef6c553..e6879c0007 100644 --- a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/ILocalizationDictionary.cs +++ b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/ILocalizationDictionary.cs @@ -13,6 +13,4 @@ public interface ILocalizationDictionary LocalizedString? GetOrNull(string name); void Fill(Dictionary dictionary); - - void Merge(ILocalizationDictionary dictionary); } diff --git a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/StaticLocalizationDictionary.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/StaticLocalizationDictionary.cs index 213627e1c6..b363076fe0 100644 --- a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/StaticLocalizationDictionary.cs +++ b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/StaticLocalizationDictionary.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using Microsoft.Extensions.Localization; @@ -38,22 +37,4 @@ public class StaticLocalizationDictionary : ILocalizationDictionary dictionary[item.Key] = item.Value; } } - - public void Merge(ILocalizationDictionary dictionary) - { - if (dictionary is not StaticLocalizationDictionary staticLocalizationDictionary) - { - return; // do nothing, we have no idea what to do with such - } - - foreach (var item in staticLocalizationDictionary.Dictionary) - { - if (string.IsNullOrEmpty(item.Key)) - { - throw new AbpException("The key is empty in given json string."); - } - - Dictionary[item.Key] = item.Value; - } - } } diff --git a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs index 0c3b26aa60..b18eba1f46 100644 --- a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs +++ b/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; @@ -90,13 +91,10 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza { var dictionaries = new Dictionary(); - 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) @@ -110,7 +108,10 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza } else { - dictionaries[dictionary.CultureName].Merge(dictionary); + var merged = new Dictionary(); + dictionaries[dictionary.CultureName].Fill(merged); + dictionary.Fill(merged); + dictionaries[dictionary.CultureName] = new StaticLocalizationDictionary(dictionary.CultureName, merged); } } diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs index 424cb8af34..31bc5a8b4f 100644 --- a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs +++ b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpLocalization_Tests.cs @@ -240,18 +240,26 @@ public class AbpLocalization_Tests : AbpIntegratedTest 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"); } } diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs index f9eb25a552..9dbc5b8c1f 100644 --- a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs +++ b/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; @@ -92,7 +93,7 @@ public class AbpStringLocalizerFactory_Tests : AbpIntegratedTest 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)"); + } + } +} diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs index 93526b2aa4..9a43a63886 100644 --- a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs +++ b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Text; -using NSubstitute.ExceptionExtensions; -using Shouldly; +using Shouldly; using Volo.Abp.Localization.Json; -using Volo.Abp.Testing; using Xunit; namespace Volo.Abp.Localization; @@ -12,14 +7,28 @@ namespace Volo.Abp.Localization; /// /// Testing edge cases for /// -public class JsonLocalizationDictionaryBuilder_Tests : AbpIntegratedTest +public class JsonLocalizationDictionaryBuilder_Tests { [Fact] public void JsonLocalizationDictionaryBuilder_Should_Handle_Duplicates() { - var localizationDictionary = JsonLocalizationDictionaryBuilder - .BuildFromJsonString("{\r\n \"culture\": \"en\",\r\n \"texts\": {\r\n \"ThisFieldIsRequired\": \"This field is required\",\r\n \"MaxLenghtErrorMessage\": \"This field can be maximum of '{0}' chars\",\r\n \"Enum:BookType.Undefined\": \"Undefined from ValidationResource\",\r\n \"Enum:BookType.0\": \"Undefined with value 0 from ValidationResource\",\r\n \"BookType.Adventure\": \"Adventure from ValidationResource\",\r\n \"BookType.1\": \"Adventure with value 1 from ValidationResource\",\r\n \"Biography\": \"Biography from ValidationResource\",\r\n \"ThisFieldIsRequired\": \"This field is required again\"\r\n }\r\n}"); + var input = """ + { + "culture": "en", + "texts": { + "ThisFieldIsRequired": "This field is required", + "MaxLenghtErrorMessage": "This field can be maximum of '{0}' chars", + "Enum:BookType.Undefined": "Undefined from ValidationResource", + "Enum:BookType.0": "Undefined with value 0 from ValidationResource", + "BookType.Adventure": "Adventure from ValidationResource", + "BookType.1": "Adventure with value 1 from ValidationResource", + "Biography": "Biography from ValidationResource", + "ThisFieldIsRequired": "This field is required again" + } + } + """; + var localizationDictionary = JsonLocalizationDictionaryBuilder.BuildFromJsonString(input); var localizationString = localizationDictionary.GetOrNull("ThisFieldIsRequired"); localizationString.ShouldNotBeNull(); @@ -29,18 +38,20 @@ public class JsonLocalizationDictionaryBuilder_Tests : AbpIntegratedTest Date: Thu, 9 Apr 2026 18:13:32 +0800 Subject: [PATCH 4/7] docs: clarify localization merging with example --- .../en/framework/fundamentals/localization.md | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/en/framework/fundamentals/localization.md b/docs/en/framework/fundamentals/localization.md index 84366197e2..5fd243eeef 100644 --- a/docs/en/framework/fundamentals/localization.md +++ b/docs/en/framework/fundamentals/localization.md @@ -126,9 +126,42 @@ 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. +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. -> Note: If the same key is defined in multiple files for the same culture, the last value wins. Files are sorted by name using ordinal (byte-order) string comparison before merging, so if `!en.json` and `zen_Book.json` both define the same key, the value from `zen_Book.json` is used. +**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 From 9c5015b8ec591610e10303c63f7df3faaef418db Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 9 Apr 2026 18:18:48 +0800 Subject: [PATCH 5/7] Refactor localization dictionary handling and improve test cases for split localization files --- ...FileLocalizationResourceContributorBase.cs | 21 +++++++++---------- .../AbpStringLocalizerFactory_Tests.cs | 2 +- ...JsonLocalizationDictionaryBuilder_Tests.cs | 20 +++++++----------- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs index b18eba1f46..d65e36a8f1 100644 --- a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs +++ b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs @@ -89,7 +89,7 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza private Dictionary CreateDictionaries() { - var dictionaries = new Dictionary(); + var rawDictionaries = new Dictionary>(); foreach (var file in _virtualFileProvider.GetDirectoryContents(_virtualPath) .Where(f => !f.IsDirectory && CanParseFile(f)) @@ -102,20 +102,19 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza continue; } - if (!dictionaries.ContainsKey(dictionary.CultureName)) + if (!rawDictionaries.TryGetValue(dictionary.CultureName, out var raw)) { - dictionaries[dictionary.CultureName] = dictionary; - } - else - { - var merged = new Dictionary(); - dictionaries[dictionary.CultureName].Fill(merged); - dictionary.Fill(merged); - dictionaries[dictionary.CultureName] = new StaticLocalizationDictionary(dictionary.CultureName, merged); + raw = new Dictionary(); + rawDictionaries[dictionary.CultureName] = raw; } + + 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); diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs index 9dbc5b8c1f..c39ff9b105 100644 --- a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs +++ b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs @@ -93,7 +93,7 @@ public class AbpStringLocalizerFactory_Tests : AbpIntegratedTest Date: Thu, 9 Apr 2026 18:27:48 +0800 Subject: [PATCH 6/7] refactor: remove unused resource variable and update test to exclude parent cultures --- .../VirtualFileLocalizationResourceContributorBase.cs | 2 -- .../Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs index d65e36a8f1..41fd38a430 100644 --- a/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs +++ b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs @@ -20,7 +20,6 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza private Dictionary? _dictionaries; private bool _subscribedForChanges; private readonly object _syncObj = new object(); - private LocalizationResourceBase _resource = default!; protected VirtualFileLocalizationResourceContributorBase(string virtualPath) { @@ -29,7 +28,6 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza public virtual void Initialize(LocalizationResourceInitializationContext context) { - _resource = context.Resource; _virtualFileProvider = context.ServiceProvider.GetRequiredService(); } diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs index c39ff9b105..32b1803c3d 100644 --- a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs +++ b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/AbpStringLocalizerFactory_Tests.cs @@ -113,7 +113,7 @@ public class AbpStringLocalizerFactory_Tests : AbpIntegratedTest ls.Name == "Base.Id" && ls.Value == "Id"); allStrings.ShouldContain(ls => ls.Name == "Base.Name" && ls.Value == "Name"); From 5f2d552ebdefb32fe2f417294d97f89aeb71b2c3 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 9 Apr 2026 18:53:57 +0800 Subject: [PATCH 7/7] restore behavior test for duplicate JSON keys with clearer naming and comment --- ...JsonLocalizationDictionaryBuilder_Tests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs index c0fd674f43..d71111136c 100644 --- a/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs +++ b/framework/test/Volo.Abp.Localization.Tests/Volo/Abp/Localization/JsonLocalizationDictionaryBuilder_Tests.cs @@ -9,6 +9,29 @@ namespace Volo.Abp.Localization; /// 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() {