diff --git a/docs/en/framework/fundamentals/localization.md b/docs/en/framework/fundamentals/localization.md index 562446a892..5fd243eeef 100644 --- a/docs/en/framework/fundamentals/localization.md +++ b/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: 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/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs b/framework/src/Volo.Abp.Localization/Volo/Abp/Localization/VirtualFiles/VirtualFileLocalizationResourceContributorBase.cs index d151dc78df..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 @@ -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? _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(); } @@ -88,15 +87,12 @@ public abstract class VirtualFileLocalizationResourceContributorBase : ILocaliza private Dictionary CreateDictionaries() { - var dictionaries = new Dictionary(); + var rawDictionaries = 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) @@ -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(); + 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); 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..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 @@ -234,6 +234,35 @@ 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"); + } + } + [Fact] public void GetAllStrings_With_Parents() { 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 dbc007ac02..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 @@ -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 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 new file mode 100644 index 0000000000..d71111136c --- /dev/null +++ b/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; + +/// +/// Testing edge cases for +/// +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"); + } +} 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