From 8caee5297bd3bfc9ee38a75cd26716555222b7d4 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Wed, 1 Feb 2023 09:37:37 +0100 Subject: [PATCH] Use ISO codes instead of language IDs for fallback languages and translations (#13751) * Use language ISO code for language fallback instead of language ID * Remove language and language ID from dictionary item and dictionary item translation * ADd unit test for dictionary item translation value extension * Make the internal service implementations sealed * Rename translation ISO code to be more explicit in its origin (Language) * Add breaking changes suppression * Handle save of invalid fallback iso code * Fixed test * Only allow non-UserCustomCulture's * Fixed and added tests * Rename ISO code validation method * Fix language telemetry test (create Swedish with the correct ISO code) --------- Co-authored-by: Bjarke Berg --- .../Language/AllLanguageController.cs | 10 +- .../Language/ByIsoCodeLanguageController.cs | 10 +- .../Language/CreateLanguageController.cs | 10 +- .../Language/LanguageControllerBase.cs | 4 + .../Language/UpdateLanguageController.cs | 9 +- .../LanguageBuilderExtensions.cs | 6 +- .../Factories/DictionaryFactory.cs | 6 +- .../Factories/ILanguageFactory.cs | 13 -- .../Factories/LanguageFactory.cs | 51 ------ .../Dictionary/DictionaryMapDefinition.cs | 6 +- .../LanguageViewModelsMapDefinition.cs | 9 +- .../CompatibilitySuppressions.xml | 156 +++++++++++++++++- .../Dictionary/UmbracoCultureDictionary.cs | 7 +- .../Models/ContentEditing/Language.cs | 4 +- src/Umbraco.Core/Models/DictionaryItem.cs | 18 +- .../Models/DictionaryItemExtensions.cs | 20 +-- .../Models/DictionaryTranslation.cs | 76 +-------- .../Models/IDictionaryTranslation.cs | 8 +- src/Umbraco.Core/Models/ILanguage.cs | 4 +- src/Umbraco.Core/Models/Language.cs | 8 +- .../Models/Mapping/DictionaryMapDefinition.cs | 6 +- .../Models/Mapping/LanguageMapDefinition.cs | 2 +- .../PublishedValueFallback.cs | 139 ++++------------ .../Services/DictionaryItemService.cs | 69 +------- .../Services/EntityXmlSerializer.cs | 3 +- src/Umbraco.Core/Services/LanguageService.cs | 36 ++-- .../Services/LocalizationService.cs | 35 +--- .../LanguageOperationStatus.cs | 1 + .../Packaging/PackageDataInstallation.cs | 2 +- .../Factories/DictionaryItemFactory.cs | 29 +--- .../Factories/DictionaryTranslationFactory.cs | 13 +- .../Persistence/Factories/LanguageFactory.cs | 8 +- .../Mappers/DictionaryTranslationMapper.cs | 3 - .../Implement/DictionaryRepository.cs | 63 +++++-- .../Implement/LanguageRepository.cs | 116 +++++++++---- .../Controllers/LanguageController.cs | 22 +-- .../Builders/LanguageBuilder.cs | 10 +- .../CompatibilitySuppressions.xml | 10 ++ .../Services/TelemetryProviderTests.cs | 8 +- .../Packaging/PackageDataInstallationTests.cs | 2 +- .../Repositories/DictionaryRepositoryTest.cs | 2 +- .../Repositories/LanguageRepositoryTest.cs | 20 +-- .../Scoping/ScopedRepositoryTests.cs | 2 +- .../Services/DictionaryItemServiceTests.cs | 4 +- .../Services/Importing/Dictionary-Package.xml | 8 +- .../Importing/ImportResources.Designer.cs | 8 +- .../Services/LanguageServiceTests.cs | 95 ++++++++--- .../Services/LocalizationServiceTests.cs | 4 +- .../Models/DictionaryItemTests.cs | 27 ++- .../Models/DictionaryTranslationTests.cs | 7 +- .../DictionaryTranslationMapperTest.cs | 11 -- .../PublishedContentLanguageVariantTests.cs | 30 +++- 52 files changed, 603 insertions(+), 627 deletions(-) delete mode 100644 src/Umbraco.Cms.Api.Management/Factories/ILanguageFactory.cs delete mode 100644 src/Umbraco.Cms.Api.Management/Factories/LanguageFactory.cs create mode 100644 tests/Umbraco.Tests.Common/CompatibilitySuppressions.xml diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Language/AllLanguageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Language/AllLanguageController.cs index 93769810bd..16f4971b2d 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Language/AllLanguageController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Language/AllLanguageController.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; -using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Language; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -11,12 +11,12 @@ namespace Umbraco.Cms.Api.Management.Controllers.Language; public class AllLanguageController : LanguageControllerBase { private readonly ILanguageService _languageService; - private readonly ILanguageFactory _languageFactory; + private readonly IUmbracoMapper _umbracoMapper; - public AllLanguageController(ILanguageService languageService, ILanguageFactory languageFactory) + public AllLanguageController(ILanguageService languageService, IUmbracoMapper umbracoMapper) { _languageService = languageService; - _languageFactory = languageFactory; + _umbracoMapper = umbracoMapper; } [HttpGet] @@ -28,7 +28,7 @@ public class AllLanguageController : LanguageControllerBase var viewModel = new PagedViewModel { Total = allLanguages.Length, - Items = allLanguages.Skip(skip).Take(take).Select(_languageFactory.CreateLanguageViewModel).ToArray() + Items = _umbracoMapper.MapEnumerable(allLanguages.Skip(skip).Take(take)) }; return await Task.FromResult(Ok(viewModel)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Language/ByIsoCodeLanguageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Language/ByIsoCodeLanguageController.cs index d54076d957..b8645fe935 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Language/ByIsoCodeLanguageController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Language/ByIsoCodeLanguageController.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Language; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -10,12 +10,12 @@ namespace Umbraco.Cms.Api.Management.Controllers.Language; public class ByIsoCodeLanguageController : LanguageControllerBase { private readonly ILanguageService _languageService; - private readonly ILanguageFactory _languageFactory; + private readonly IUmbracoMapper _umbracoMapper; - public ByIsoCodeLanguageController(ILanguageService languageService, ILanguageFactory languageFactory) + public ByIsoCodeLanguageController(ILanguageService languageService, IUmbracoMapper umbracoMapper) { _languageService = languageService; - _languageFactory = languageFactory; + _umbracoMapper = umbracoMapper; } [HttpGet($"{{{nameof(isoCode)}}}")] @@ -30,6 +30,6 @@ public class ByIsoCodeLanguageController : LanguageControllerBase return NotFound(); } - return Ok(_languageFactory.CreateLanguageViewModel(language)); + return Ok(_umbracoMapper.Map(language)!); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Language/CreateLanguageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Language/CreateLanguageController.cs index 58042e0848..61ebec8706 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Language/CreateLanguageController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Language/CreateLanguageController.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Language; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -12,17 +12,17 @@ namespace Umbraco.Cms.Api.Management.Controllers.Language; public class CreateLanguageController : LanguageControllerBase { - private readonly ILanguageFactory _languageFactory; private readonly ILanguageService _languageService; + private readonly IUmbracoMapper _umbracoMapper; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; public CreateLanguageController( - ILanguageFactory languageFactory, ILanguageService languageService, + IUmbracoMapper umbracoMapper, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { - _languageFactory = languageFactory; _languageService = languageService; + _umbracoMapper = umbracoMapper; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } @@ -33,7 +33,7 @@ public class CreateLanguageController : LanguageControllerBase [ProducesResponseType(StatusCodes.Status201Created)] public async Task Create(LanguageCreateModel languageCreateModel) { - ILanguage created = _languageFactory.MapCreateModelToLanguage(languageCreateModel); + ILanguage created = _umbracoMapper.Map(languageCreateModel)!; Attempt result = await _languageService.CreateAsync(created, CurrentUserId(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Language/LanguageControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Language/LanguageControllerBase.cs index 6e77972cab..ee88d8673b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Language/LanguageControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Language/LanguageControllerBase.cs @@ -32,6 +32,10 @@ public abstract class LanguageControllerBase : ManagementApiControllerBase .WithTitle("Invalid ISO code") .WithDetail("The attempted ISO code does not represent a valid culture.") .Build()), + LanguageOperationStatus.InvalidFallbackIsoCode => BadRequest(new ProblemDetailsBuilder() + .WithTitle("Invalid Fallback ISO code") + .WithDetail("The attempted fallback ISO code does not represent a valid culture.") + .Build()), LanguageOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() .WithTitle("Cancelled by notification") .WithDetail("A notification handler prevented the language operation.") diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Language/UpdateLanguageController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Language/UpdateLanguageController.cs index 302522cbd0..a2c014c4f4 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Language/UpdateLanguageController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Language/UpdateLanguageController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.ViewModels.Language; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -12,17 +13,17 @@ namespace Umbraco.Cms.Api.Management.Controllers.Language; public class UpdateLanguageController : LanguageControllerBase { - private readonly ILanguageFactory _languageFactory; private readonly ILanguageService _languageService; + private readonly IUmbracoMapper _umbracoMapper; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; public UpdateLanguageController( - ILanguageFactory languageFactory, ILanguageService languageService, + IUmbracoMapper umbracoMapper, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) { - _languageFactory = languageFactory; _languageService = languageService; + _umbracoMapper = umbracoMapper; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } @@ -39,7 +40,7 @@ public class UpdateLanguageController : LanguageControllerBase return NotFound(); } - ILanguage updated = _languageFactory.MapUpdateModelToLanguage(current, languageUpdateModel); + ILanguage updated = _umbracoMapper.Map(languageUpdateModel, current); Attempt result = await _languageService.UpdateAsync(updated, CurrentUserId(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/LanguageBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/LanguageBuilderExtensions.cs index b3bd404eef..9fc9fb475a 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/LanguageBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/LanguageBuilderExtensions.cs @@ -1,6 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Api.Management.Mapping.Culture; +using Umbraco.Cms.Api.Management.Mapping.Culture; using Umbraco.Cms.Api.Management.Mapping.Language; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; @@ -11,8 +9,6 @@ internal static class LanguageBuilderExtensions { internal static IUmbracoBuilder AddLanguages(this IUmbracoBuilder builder) { - builder.Services.AddTransient(); - builder.WithCollectionBuilder() .Add() .Add(); diff --git a/src/Umbraco.Cms.Api.Management/Factories/DictionaryFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DictionaryFactory.cs index 2ed132d7b4..a1125da8d5 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DictionaryFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DictionaryFactory.cs @@ -23,11 +23,11 @@ public class DictionaryFactory : IDictionaryFactory { DictionaryItemViewModel dictionaryViewModel = _umbracoMapper.Map(dictionaryItem)!; - var validLanguageIds = (await _languageService.GetAllAsync()) - .Select(language => language.Id) + var validLanguageIsoCodes = (await _languageService.GetAllAsync()) + .Select(language => language.IsoCode) .ToArray(); IDictionaryTranslation[] validTranslations = dictionaryItem.Translations - .Where(t => validLanguageIds.Contains(t.LanguageId)) + .Where(t => validLanguageIsoCodes.Contains(t.LanguageIsoCode)) .ToArray(); dictionaryViewModel.Translations = validTranslations .Select(translation => _umbracoMapper.Map(translation)) diff --git a/src/Umbraco.Cms.Api.Management/Factories/ILanguageFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/ILanguageFactory.cs deleted file mode 100644 index 55ede7f563..0000000000 --- a/src/Umbraco.Cms.Api.Management/Factories/ILanguageFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Umbraco.Cms.Api.Management.ViewModels.Language; -using Umbraco.Cms.Core.Models; - -namespace Umbraco.Cms.Api.Management.Factories; - -public interface ILanguageFactory -{ - LanguageViewModel CreateLanguageViewModel(ILanguage language); - - ILanguage MapCreateModelToLanguage(LanguageCreateModel languageCreateModel); - - ILanguage MapUpdateModelToLanguage(ILanguage current, LanguageUpdateModel languageUpdateModel); -} diff --git a/src/Umbraco.Cms.Api.Management/Factories/LanguageFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/LanguageFactory.cs deleted file mode 100644 index ef558fdba3..0000000000 --- a/src/Umbraco.Cms.Api.Management/Factories/LanguageFactory.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Umbraco.Cms.Api.Management.ViewModels.Language; -using Umbraco.Cms.Core.Mapping; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Services; - -namespace Umbraco.Cms.Api.Management.Factories; - -public class LanguageFactory : ILanguageFactory -{ - // FIXME: use ILanguageService instead of ILocalizationService (pending language refactor to replace fallback language ID with fallback language IsoCode) - private readonly ILocalizationService _localizationService; - private readonly IUmbracoMapper _umbracoMapper; - - public LanguageFactory(ILocalizationService localizationService, IUmbracoMapper umbracoMapper) - { - _localizationService = localizationService; - _umbracoMapper = umbracoMapper; - } - - public LanguageViewModel CreateLanguageViewModel(ILanguage language) - { - LanguageViewModel languageViewModel = _umbracoMapper.Map(language)!; - if (language.FallbackLanguageId.HasValue) - { - languageViewModel.FallbackIsoCode = _localizationService.GetLanguageById(language.FallbackLanguageId.Value)?.IsoCode; - } - - return languageViewModel; - } - - public ILanguage MapCreateModelToLanguage(LanguageCreateModel languageCreateModel) - { - ILanguage created = _umbracoMapper.Map(languageCreateModel)!; - created.FallbackLanguageId = GetFallbackLanguageId(languageCreateModel); - - return created; - } - - public ILanguage MapUpdateModelToLanguage(ILanguage current, LanguageUpdateModel languageUpdateModel) - { - ILanguage updated = _umbracoMapper.Map(languageUpdateModel, current); - updated.FallbackLanguageId = GetFallbackLanguageId(languageUpdateModel); - - return updated; - } - - private int? GetFallbackLanguageId(LanguageModelBase languageModelBase) => - string.IsNullOrWhiteSpace(languageModelBase.FallbackIsoCode) - ? null - : _localizationService.GetLanguageByIsoCode(languageModelBase.FallbackIsoCode)?.Id; -} diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Dictionary/DictionaryMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Dictionary/DictionaryMapDefinition.cs index 33fff1541c..17214fe6d9 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Dictionary/DictionaryMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Dictionary/DictionaryMapDefinition.cs @@ -26,7 +26,7 @@ public class DictionaryMapDefinition : IMapDefinition // Umbraco.Code.MapAll private void Map(IDictionaryTranslation source, DictionaryItemTranslationModel target, MapperContext context) { - target.IsoCode = source.Language?.IsoCode ?? throw new ArgumentException("Translation has no language", nameof(source)); + target.IsoCode = source.LanguageIsoCode; target.Translation = source.Value; } @@ -52,8 +52,8 @@ public class DictionaryMapDefinition : IMapDefinition target.Name = source.ItemKey; target.TranslatedIsoCodes = source .Translations - .Where(translation => translation.Value.IsNullOrWhiteSpace() == false && translation.Language != null) - .Select(translation => translation.Language!.IsoCode) + .Where(translation => translation.Value.IsNullOrWhiteSpace() == false) + .Select(translation => translation.LanguageIsoCode) .ToArray(); } } diff --git a/src/Umbraco.Cms.Api.Management/Mapping/Language/LanguageViewModelsMapDefinition.cs b/src/Umbraco.Cms.Api.Management/Mapping/Language/LanguageViewModelsMapDefinition.cs index 9f71dce075..46fd6e0812 100644 --- a/src/Umbraco.Cms.Api.Management/Mapping/Language/LanguageViewModelsMapDefinition.cs +++ b/src/Umbraco.Cms.Api.Management/Mapping/Language/LanguageViewModelsMapDefinition.cs @@ -13,16 +13,17 @@ public class LanguageViewModelsMapDefinition : IMapDefinition mapper.Define((_, _) => new LanguageViewModel(), Map); } - // Umbraco.Code.MapAll -FallbackIsoCode + // Umbraco.Code.MapAll private static void Map(ILanguage source, LanguageViewModel target, MapperContext context) { target.IsoCode = source.IsoCode; + target.FallbackIsoCode = source.FallbackIsoCode; target.Name = source.CultureName; target.IsDefault = source.IsDefault; target.IsMandatory = source.IsMandatory; } - // Umbraco.Code.MapAll -Id -FallbackLanguageId -Key + // Umbraco.Code.MapAll -Id -Key private static void Map(LanguageCreateModel source, ILanguage target, MapperContext context) { target.CreateDate = default; @@ -35,9 +36,10 @@ public class LanguageViewModelsMapDefinition : IMapDefinition target.IsMandatory = source.IsMandatory; target.IsoCode = source.IsoCode; target.UpdateDate = default; + target.FallbackIsoCode = source.FallbackIsoCode; } - // Umbraco.Code.MapAll -Id -FallbackLanguageId -Key -IsoCode -CreateDate + // Umbraco.Code.MapAll -Id -Key -IsoCode -CreateDate private static void Map(LanguageUpdateModel source, ILanguage target, MapperContext context) { if (!string.IsNullOrEmpty(source.Name)) @@ -48,5 +50,6 @@ public class LanguageViewModelsMapDefinition : IMapDefinition target.IsDefault = source.IsDefault; target.IsMandatory = source.IsMandatory; target.UpdateDate = default; + target.FallbackIsoCode = source.FallbackIsoCode; } } diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml index 276e3c0ed9..47d170282c 100644 --- a/src/Umbraco.Core/CompatibilitySuppressions.xml +++ b/src/Umbraco.Core/CompatibilitySuppressions.xml @@ -42,6 +42,20 @@ lib/net7.0/Umbraco.Core.dll true + + CP0002 + M:Umbraco.Cms.Core.Models.ContentEditing.Language.get_FallbackLanguageId + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.ContentEditing.Language.set_FallbackLanguageId(System.Nullable{System.Int32}) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0002 M:Umbraco.Cms.Core.Models.DataType.get_Configuration @@ -63,6 +77,69 @@ lib/net7.0/Umbraco.Core.dll true + + CP0002 + M:Umbraco.Cms.Core.Models.DictionaryItem.get_GetLanguage + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.DictionaryItem.set_GetLanguage(System.Func{System.Int32,Umbraco.Cms.Core.Models.ILanguage}) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.DictionaryTranslation.#ctor(System.Int32,System.String,System.Guid) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.DictionaryTranslation.#ctor(System.Int32,System.String) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.DictionaryTranslation.get_GetLanguage + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.DictionaryTranslation.get_Language + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.DictionaryTranslation.get_LanguageId + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.DictionaryTranslation.set_GetLanguage(System.Func{System.Int32,Umbraco.Cms.Core.Models.ILanguage}) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.DictionaryTranslation.set_Language(Umbraco.Cms.Core.Models.ILanguage) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0002 M:Umbraco.Cms.Core.Models.IDataType.get_Configuration @@ -77,6 +154,55 @@ lib/net7.0/Umbraco.Core.dll true + + CP0002 + M:Umbraco.Cms.Core.Models.IDictionaryTranslation.get_Language + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.IDictionaryTranslation.get_LanguageId + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.IDictionaryTranslation.set_Language(Umbraco.Cms.Core.Models.ILanguage) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.ILanguage.get_FallbackLanguageId + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.ILanguage.set_FallbackLanguageId(System.Nullable{System.Int32}) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.Language.get_FallbackLanguageId + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Cms.Core.Models.Language.set_FallbackLanguageId(System.Nullable{System.Int32}) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0002 M:Umbraco.Cms.Core.PropertyEditors.ConfigurationEditor.FromConfigurationEditor(System.Collections.Generic.IDictionary{System.String,System.Object},System.Object) @@ -203,6 +329,20 @@ lib/net7.0/Umbraco.Core.dll true + + CP0002 + M:Umbraco.Extensions.DictionaryItemExtensions.GetDefaultValue(Umbraco.Cms.Core.Models.IDictionaryItem) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0002 + M:Umbraco.Extensions.DictionaryItemExtensions.GetTranslatedValue(Umbraco.Cms.Core.Models.IDictionaryItem,System.Int32) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0002 M:Umbraco.Cms.Core.Services.Implement.DataTypeService.#ctor(Umbraco.Cms.Core.PropertyEditors.IDataValueEditorFactory,Umbraco.Cms.Core.Scoping.ICoreScopeProvider,Microsoft.Extensions.Logging.ILoggerFactory,Umbraco.Cms.Core.Events.IEventMessagesFactory,Umbraco.Cms.Core.Persistence.Repositories.IDataTypeRepository,Umbraco.Cms.Core.Persistence.Repositories.IDataTypeContainerRepository,Umbraco.Cms.Core.Persistence.Repositories.IAuditRepository,Umbraco.Cms.Core.Persistence.Repositories.IEntityRepository,Umbraco.Cms.Core.Persistence.Repositories.IContentTypeRepository,Umbraco.Cms.Core.IO.IIOHelper,Umbraco.Cms.Core.Services.ILocalizedTextService,Umbraco.Cms.Core.Services.ILocalizationService,Umbraco.Cms.Core.Strings.IShortStringHelper,Umbraco.Cms.Core.Serialization.IJsonSerializer) @@ -420,4 +560,18 @@ lib/net7.0/Umbraco.Core.dll true - \ No newline at end of file + + CP0006 + P:Umbraco.Cms.Core.Models.IDictionaryTranslation.LanguageIsoCode + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + + CP0006 + P:Umbraco.Cms.Core.Models.ILanguage.FallbackIsoCode + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + + diff --git a/src/Umbraco.Core/Dictionary/UmbracoCultureDictionary.cs b/src/Umbraco.Core/Dictionary/UmbracoCultureDictionary.cs index de968f1676..541e479d45 100644 --- a/src/Umbraco.Core/Dictionary/UmbracoCultureDictionary.cs +++ b/src/Umbraco.Core/Dictionary/UmbracoCultureDictionary.cs @@ -90,8 +90,7 @@ internal class DefaultCultureDictionary : ICultureDictionary return string.Empty; } - IDictionaryTranslation? byLang = - found.Translations.FirstOrDefault(x => x.Language?.Equals(Language) ?? false); + IDictionaryTranslation? byLang = found.Translations.FirstOrDefault(IsCurrentLanguage); if (byLang == null) { return string.Empty; @@ -129,7 +128,7 @@ internal class DefaultCultureDictionary : ICultureDictionary foreach (IDictionaryItem dictionaryItem in children) { - IDictionaryTranslation? byLang = dictionaryItem.Translations.FirstOrDefault(x => x.Language?.Equals(Language) ?? false); + IDictionaryTranslation? byLang = dictionaryItem.Translations.FirstOrDefault(IsCurrentLanguage); if (byLang != null && dictionaryItem.ItemKey is not null && byLang.Value is not null) { result.Add(dictionaryItem.ItemKey, byLang.Value); @@ -138,4 +137,6 @@ internal class DefaultCultureDictionary : ICultureDictionary return result; } + + private bool IsCurrentLanguage(IDictionaryTranslation translation) => translation.LanguageIsoCode.Equals(Language?.IsoCode); } diff --git a/src/Umbraco.Core/Models/ContentEditing/Language.cs b/src/Umbraco.Core/Models/ContentEditing/Language.cs index 15e63eabed..12ecbc372f 100644 --- a/src/Umbraco.Core/Models/ContentEditing/Language.cs +++ b/src/Umbraco.Core/Models/ContentEditing/Language.cs @@ -22,6 +22,6 @@ public class Language [DataMember(Name = "isMandatory")] public bool IsMandatory { get; set; } - [DataMember(Name = "fallbackLanguageId")] - public int? FallbackLanguageId { get; set; } + [DataMember(Name = "fallbackIsoCode")] + public string? FallbackIsoCode { get; set; } } diff --git a/src/Umbraco.Core/Models/DictionaryItem.cs b/src/Umbraco.Core/Models/DictionaryItem.cs index 7473cef60f..0f0d2c3d0b 100644 --- a/src/Umbraco.Core/Models/DictionaryItem.cs +++ b/src/Umbraco.Core/Models/DictionaryItem.cs @@ -34,8 +34,6 @@ public class DictionaryItem : EntityBase, IDictionaryItem _translations = new List(); } - public Func? GetLanguage { get; set; } - /// /// Gets or Sets the Parent Id of the Dictionary Item /// @@ -63,20 +61,6 @@ public class DictionaryItem : EntityBase, IDictionaryItem public IEnumerable Translations { get => _translations; - set - { - IDictionaryTranslation[] asArray = value.ToArray(); - - // ensure the language callback is set on each translation - if (GetLanguage != null) - { - foreach (DictionaryTranslation translation in asArray.OfType()) - { - translation.GetLanguage = GetLanguage; - } - } - - SetPropertyValueAndDetectChanges(asArray, ref _translations!, nameof(Translations), DictionaryTranslationComparer); - } + set => SetPropertyValueAndDetectChanges(value, ref _translations!, nameof(Translations), DictionaryTranslationComparer); } } diff --git a/src/Umbraco.Core/Models/DictionaryItemExtensions.cs b/src/Umbraco.Core/Models/DictionaryItemExtensions.cs index fc03cb929c..f06c053d14 100644 --- a/src/Umbraco.Core/Models/DictionaryItemExtensions.cs +++ b/src/Umbraco.Core/Models/DictionaryItemExtensions.cs @@ -5,27 +5,25 @@ namespace Umbraco.Extensions; public static class DictionaryItemExtensions { /// - /// Returns the translation value for the language id, if no translation is found it returns an empty string + /// Returns the translation value for the language ISO code, if no translation is found it returns an empty string /// /// - /// + /// /// - public static string? GetTranslatedValue(this IDictionaryItem d, int languageId) + public static string? GetTranslatedValue(this IDictionaryItem d, string isoCode) { - IDictionaryTranslation? trans = d.Translations.FirstOrDefault(x => x.LanguageId == languageId); + IDictionaryTranslation? trans = d.Translations.FirstOrDefault(x => x.LanguageIsoCode == isoCode); return trans == null ? string.Empty : trans.Value; } /// - /// Returns the default translated value based on the default language + /// Returns the translation value for the language, if no translation is found it returns an empty string /// /// + /// /// - public static string? GetDefaultValue(this IDictionaryItem d) - { - IDictionaryTranslation? defaultTranslation = d.Translations.FirstOrDefault(x => x.Language?.Id == 1); - return defaultTranslation == null ? string.Empty : defaultTranslation.Value; - } + public static string? GetTranslatedValue(this IDictionaryItem d, ILanguage language) + => d.GetTranslatedValue(language.IsoCode); /// /// Adds or updates a translation for a dictionary item and language @@ -35,7 +33,7 @@ public static class DictionaryItemExtensions /// public static void AddOrUpdateDictionaryValue(this IDictionaryItem item, ILanguage language, string value) { - IDictionaryTranslation? existing = item.Translations?.FirstOrDefault(x => x.Language?.Id == language.Id); + IDictionaryTranslation? existing = item.Translations?.FirstOrDefault(x => x.LanguageIsoCode.Equals(language.IsoCode)); if (existing != null) { existing.Value = value; diff --git a/src/Umbraco.Core/Models/DictionaryTranslation.cs b/src/Umbraco.Core/Models/DictionaryTranslation.cs index 5d44768388..70b75bd44b 100644 --- a/src/Umbraco.Core/Models/DictionaryTranslation.cs +++ b/src/Umbraco.Core/Models/DictionaryTranslation.cs @@ -10,80 +10,20 @@ namespace Umbraco.Cms.Core.Models; [DataContract(IsReference = true)] public class DictionaryTranslation : EntityBase, IDictionaryTranslation { - private ILanguage? _language; - // note: this will be memberwise cloned private string _value; public DictionaryTranslation(ILanguage language, string value) { - _language = language ?? throw new ArgumentNullException("language"); - LanguageId = _language.Id; + LanguageIsoCode = language.IsoCode; _value = value; } public DictionaryTranslation(ILanguage language, string value, Guid uniqueId) - { - _language = language ?? throw new ArgumentNullException("language"); - LanguageId = _language.Id; - _value = value; + : this(language, value) => Key = uniqueId; - } - public DictionaryTranslation(int languageId, string value) - { - LanguageId = languageId; - _value = value; - } - - public DictionaryTranslation(int languageId, string value, Guid uniqueId) - { - LanguageId = languageId; - _value = value; - Key = uniqueId; - } - - public Func? GetLanguage { get; set; } - - /// - /// Gets or sets the for the translation - /// - /// - /// Marked as DoNotClone - TODO: this member shouldn't really exist here in the first place, the DictionaryItem - /// class will have a deep hierarchy of objects which all get deep cloned which we don't want. This should have simply - /// just referenced a language ID not the actual language object. In v8 we need to fix this. - /// We're going to have to do the same hacky stuff we had to do with the Template/File contents so that this is - /// returned - /// on a callback. - /// - [DataMember] - [DoNotClone] - public ILanguage? Language - { - get - { - if (_language != null) - { - return _language; - } - - // else, must lazy-load - if (GetLanguage != null && LanguageId > 0) - { - _language = GetLanguage(LanguageId); - } - - return _language; - } - - set - { - SetPropertyValueAndDetectChanges(value, ref _language, nameof(Language)); - LanguageId = _language == null ? -1 : _language.Id; - } - } - - public int LanguageId { get; private set; } + public string LanguageIsoCode { get; private set; } /// /// Gets or sets the translated text @@ -94,14 +34,4 @@ public class DictionaryTranslation : EntityBase, IDictionaryTranslation get => _value; set => SetPropertyValueAndDetectChanges(value, ref _value!, nameof(Value)); } - - protected override void PerformDeepClone(object clone) - { - base.PerformDeepClone(clone); - - var clonedEntity = (DictionaryTranslation)clone; - - // clear fields that were memberwise-cloned and that we don't want to clone - clonedEntity._language = null; - } } diff --git a/src/Umbraco.Core/Models/IDictionaryTranslation.cs b/src/Umbraco.Core/Models/IDictionaryTranslation.cs index 37579151bc..ff63cd7f9c 100644 --- a/src/Umbraco.Core/Models/IDictionaryTranslation.cs +++ b/src/Umbraco.Core/Models/IDictionaryTranslation.cs @@ -5,13 +5,7 @@ namespace Umbraco.Cms.Core.Models; public interface IDictionaryTranslation : IEntity, IRememberBeingDirty { - /// - /// Gets or sets the for the translation - /// - [DataMember] - ILanguage? Language { get; set; } - - int LanguageId { get; } + string LanguageIsoCode { get; } /// /// Gets or sets the translated text diff --git a/src/Umbraco.Core/Models/ILanguage.cs b/src/Umbraco.Core/Models/ILanguage.cs index 5f48bc363e..5af66089ca 100644 --- a/src/Umbraco.Core/Models/ILanguage.cs +++ b/src/Umbraco.Core/Models/ILanguage.cs @@ -47,7 +47,7 @@ public interface ILanguage : IEntity, IRememberBeingDirty bool IsMandatory { get; set; } /// - /// Gets or sets the identifier of a fallback language. + /// Gets or sets the ISO code of a fallback language. /// /// /// @@ -56,5 +56,5 @@ public interface ILanguage : IEntity, IRememberBeingDirty /// /// [DataMember] - int? FallbackLanguageId { get; set; } + public string? FallbackIsoCode { get; set; } } diff --git a/src/Umbraco.Core/Models/Language.cs b/src/Umbraco.Core/Models/Language.cs index 2072533917..b8ea8e132e 100644 --- a/src/Umbraco.Core/Models/Language.cs +++ b/src/Umbraco.Core/Models/Language.cs @@ -13,7 +13,7 @@ namespace Umbraco.Cms.Core.Models; public class Language : EntityBase, ILanguage { private string _cultureName; - private int? _fallbackLanguageId; + private string? _fallbackLanguageIsoCode; private bool _isDefaultVariantLanguage; private string _isoCode; private bool _mandatory; @@ -74,9 +74,9 @@ public class Language : EntityBase, ILanguage } /// - public int? FallbackLanguageId + public string? FallbackIsoCode { - get => _fallbackLanguageId; - set => SetPropertyValueAndDetectChanges(value, ref _fallbackLanguageId, nameof(FallbackLanguageId)); + get => _fallbackLanguageIsoCode; + set => SetPropertyValueAndDetectChanges(value, ref _fallbackLanguageIsoCode, nameof(FallbackIsoCode)); } } diff --git a/src/Umbraco.Core/Models/Mapping/DictionaryMapDefinition.cs b/src/Umbraco.Core/Models/Mapping/DictionaryMapDefinition.cs index cda8c66e69..004b493935 100644 --- a/src/Umbraco.Core/Models/Mapping/DictionaryMapDefinition.cs +++ b/src/Umbraco.Core/Models/Mapping/DictionaryMapDefinition.cs @@ -75,8 +75,7 @@ public class DictionaryMapDefinition : IMapDefinition // add all languages and the translations foreach (ILanguage lang in _localizationService.GetAllLanguages()) { - var langId = lang.Id; - IDictionaryTranslation? translation = source.Translations?.FirstOrDefault(x => x.LanguageId == langId); + IDictionaryTranslation? translation = source.Translations?.FirstOrDefault(x => x.LanguageIsoCode == lang.IsoCode); target.Translations.Add(new DictionaryTranslationDisplay { @@ -97,8 +96,7 @@ public class DictionaryMapDefinition : IMapDefinition // add all languages and the translations foreach (ILanguage lang in _localizationService.GetAllLanguages()) { - var langId = lang.Id; - IDictionaryTranslation? translation = source.Translations?.FirstOrDefault(x => x.LanguageId == langId); + IDictionaryTranslation? translation = source.Translations?.FirstOrDefault(x => x.LanguageIsoCode == lang.IsoCode); target.Translations.Add( new DictionaryOverviewTranslationDisplay diff --git a/src/Umbraco.Core/Models/Mapping/LanguageMapDefinition.cs b/src/Umbraco.Core/Models/Mapping/LanguageMapDefinition.cs index 12d2f162f7..dd49b0a0eb 100644 --- a/src/Umbraco.Core/Models/Mapping/LanguageMapDefinition.cs +++ b/src/Umbraco.Core/Models/Mapping/LanguageMapDefinition.cs @@ -30,7 +30,7 @@ public class LanguageMapDefinition : IMapDefinition target.Name = source.CultureName; target.IsDefault = source.IsDefault; target.IsMandatory = source.IsMandatory; - target.FallbackLanguageId = source.FallbackLanguageId; + target.FallbackIsoCode = source.FallbackIsoCode; } private static void Map(IEnumerable source, IEnumerable target, MapperContext context) diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs index 64f0160383..5b18d831ee 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs @@ -195,57 +195,27 @@ public class PublishedValueFallback : IPublishedValueFallback // tries to get a value, falling back onto other languages private bool TryGetValueWithLanguageFallback(IPublishedProperty property, string? culture, string? segment, out T? value) - { - value = default; + => TryGetValueWithLanguageFallback( + (actualCulture, actualSegment) + => property.HasValue(actualCulture, actualSegment) + ? property.Value(this, actualCulture, actualSegment) + : default, + culture, + segment, + out value); - if (culture.IsNullOrWhiteSpace()) - { - return false; - } - - var visited = new HashSet(); - - ILanguage? language = culture is not null ? _localizationService?.GetLanguageByIsoCode(culture) : null; - if (language == null) - { - return false; - } - - while (true) - { - if (language.FallbackLanguageId == null) - { - return false; - } - - var language2Id = language.FallbackLanguageId.Value; - if (visited.Contains(language2Id)) - { - return false; - } - - visited.Add(language2Id); - - ILanguage? language2 = _localizationService?.GetLanguageById(language2Id); - if (language2 == null) - { - return false; - } - - var culture2 = language2.IsoCode; - - if (property.HasValue(culture2, segment)) - { - value = property.Value(this, culture2, segment); - return true; - } - - language = language2; - } - } - - // tries to get a value, falling back onto other languages + // tries to get a value, falling back onto other language private bool TryGetValueWithLanguageFallback(IPublishedElement content, string alias, string? culture, string? segment, out T? value) + => TryGetValueWithLanguageFallback( + (actualCulture, actualSegment) + => content.HasValue(alias, actualCulture, actualSegment) + ? content.Value(this, alias, actualCulture, actualSegment) + : default, + culture, + segment, + out value); + + private bool TryGetValueWithLanguageFallback(TryGetValueForCultureAndSegment getValue, string? culture, string? segment, out T? value) { value = default; @@ -254,7 +224,7 @@ public class PublishedValueFallback : IPublishedValueFallback return false; } - var visited = new HashSet(); + var visited = new HashSet(); ILanguage? language = culture is not null ? _localizationService?.GetLanguageByIsoCode(culture) : null; if (language == null) @@ -264,30 +234,30 @@ public class PublishedValueFallback : IPublishedValueFallback while (true) { - if (language.FallbackLanguageId == null) + if (language.FallbackIsoCode == null) { return false; } - var language2Id = language.FallbackLanguageId.Value; - if (visited.Contains(language2Id)) + var language2IsoCode = language.FallbackIsoCode; + if (visited.Contains(language2IsoCode)) { return false; } - visited.Add(language2Id); + visited.Add(language2IsoCode); - ILanguage? language2 = _localizationService?.GetLanguageById(language2Id); + ILanguage? language2 = _localizationService?.GetLanguageByIsoCode(language2IsoCode); if (language2 == null) { return false; } var culture2 = language2.IsoCode; - - if (content.HasValue(alias, culture2, segment)) + T? culture2Value = getValue(culture2, segment); + if (culture2Value != null) { - value = content.Value(this, alias, culture2, segment); + value = culture2Value; return true; } @@ -295,56 +265,5 @@ public class PublishedValueFallback : IPublishedValueFallback } } - // tries to get a value, falling back onto other languages - private bool TryGetValueWithLanguageFallback(IPublishedContent content, string alias, string? culture, string? segment, out T? value) - { - value = default; - - if (culture.IsNullOrWhiteSpace()) - { - return false; - } - - var visited = new HashSet(); - - // TODO: _localizationService.GetXxx() is expensive, it deep clones objects - // we want _localizationService.GetReadOnlyXxx() returning IReadOnlyLanguage which cannot be saved back = no need to clone - ILanguage? language = culture is not null ? _localizationService?.GetLanguageByIsoCode(culture) : null; - if (language == null) - { - return false; - } - - while (true) - { - if (language.FallbackLanguageId == null) - { - return false; - } - - var language2Id = language.FallbackLanguageId.Value; - if (visited.Contains(language2Id)) - { - return false; - } - - visited.Add(language2Id); - - ILanguage? language2 = _localizationService?.GetLanguageById(language2Id); - if (language2 == null) - { - return false; - } - - var culture2 = language2.IsoCode; - - if (content.HasValue(alias, culture2, segment)) - { - value = content.Value(this, alias, culture2, segment); - return true; - } - - language = language2; - } - } + private delegate T? TryGetValueForCultureAndSegment(string actualCulture, string? actualSegment); } diff --git a/src/Umbraco.Core/Services/DictionaryItemService.cs b/src/Umbraco.Core/Services/DictionaryItemService.cs index 2f8a44b1df..09711ec6c5 100644 --- a/src/Umbraco.Core/Services/DictionaryItemService.cs +++ b/src/Umbraco.Core/Services/DictionaryItemService.cs @@ -9,7 +9,7 @@ using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; -internal class DictionaryItemService : RepositoryService, IDictionaryItemService +internal sealed class DictionaryItemService : RepositoryService, IDictionaryItemService { private readonly IDictionaryRepository _dictionaryRepository; private readonly IAuditRepository _auditRepository; @@ -35,9 +35,6 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService using (ScopeProvider.CreateCoreScope(autoComplete: true)) { IDictionaryItem? item = _dictionaryRepository.Get(id); - - // ensure the lazy Language callback is assigned - EnsureDictionaryItemLanguageCallback(item); return await Task.FromResult(item); } } @@ -48,9 +45,6 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService using (ScopeProvider.CreateCoreScope(autoComplete: true)) { IDictionaryItem? item = _dictionaryRepository.Get(key); - - // ensure the lazy Language callback is assigned - EnsureDictionaryItemLanguageCallback(item); return await Task.FromResult(item); } } @@ -61,13 +55,6 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService using (ScopeProvider.CreateCoreScope(autoComplete: true)) { IEnumerable items = _dictionaryRepository.GetMany(ids).ToArray(); - - // ensure the lazy Language callback is assigned - foreach (IDictionaryItem item in items) - { - EnsureDictionaryItemLanguageCallback(item); - } - return await Task.FromResult(items); } } @@ -78,13 +65,6 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService using (ScopeProvider.CreateCoreScope(autoComplete: true)) { IEnumerable items = _dictionaryRepository.GetManyByKeys(keys).ToArray(); - - // ensure the lazy Language callback is assigned - foreach (IDictionaryItem item in items) - { - EnsureDictionaryItemLanguageCallback(item); - } - return await Task.FromResult(items); } } @@ -99,13 +79,6 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService using (ScopeProvider.CreateCoreScope(autoComplete: true)) { IDictionaryItem[] items = _dictionaryRepository.GetDictionaryItemDescendants(parentId).ToArray(); - - // ensure the lazy Language callback is assigned - foreach (IDictionaryItem item in items) - { - EnsureDictionaryItemLanguageCallback(item); - } - return await Task.FromResult(items); } } @@ -239,8 +212,6 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService _dictionaryRepository.Save(dictionaryItem); - // ensure the lazy Language callback is assigned - EnsureDictionaryItemLanguageCallback(dictionaryItem); scope.Notifications.Publish( new DictionaryItemSavedNotification(dictionaryItem, eventMessages).WithStateFrom(savingNotification)); @@ -256,13 +227,6 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService using (ScopeProvider.CreateCoreScope(autoComplete: true)) { IDictionaryItem[] items = _dictionaryRepository.Get(query).ToArray(); - - // ensure the lazy Language callback is assigned - foreach (IDictionaryItem item in items) - { - EnsureDictionaryItemLanguageCallback(item); - } - return await Task.FromResult(items); } } @@ -270,31 +234,6 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService private void Audit(AuditType type, string message, int userId, int objectId, string? entityType) => _auditRepository.Save(new AuditItem(objectId, type, userId, entityType, message)); - /// - /// This is here to take care of a hack - the DictionaryTranslation model contains an ILanguage reference which we - /// don't want but - /// we cannot remove it because it would be a large breaking change, so we need to make sure it's resolved lazily. This - /// is because - /// if developers have a lot of dictionary items and translations, the caching and cloning size gets much larger - /// because of - /// the large object graphs. So now we don't cache or clone the attached ILanguage - /// - private void EnsureDictionaryItemLanguageCallback(IDictionaryItem? d) - { - if (d is not DictionaryItem item) - { - return; - } - - // TODO: obsolete this! - item.GetLanguage = GetLanguageById; - IEnumerable translations = item.Translations.OfType(); - foreach (DictionaryTranslation trans in translations) - { - trans.GetLanguage = GetLanguageById; - } - } - private bool HasValidParent(IDictionaryItem dictionaryItem) => dictionaryItem.ParentId.HasValue == false || _dictionaryRepository.Get(dictionaryItem.ParentId.Value) != null; @@ -306,8 +245,8 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService return; } - var allLanguageIds = allLanguages.Select(language => language.Id).ToArray(); - dictionaryItem.Translations = translationsAsArray.Where(translation => allLanguageIds.Contains(translation.LanguageId)).ToArray(); + var allLanguageIsoCodes = allLanguages.Select(language => language.IsoCode).ToArray(); + dictionaryItem.Translations = translationsAsArray.Where(translation => allLanguageIsoCodes.Contains(translation.LanguageIsoCode)).ToArray(); } private bool HasItemKeyCollision(IDictionaryItem dictionaryItem) @@ -315,6 +254,4 @@ internal class DictionaryItemService : RepositoryService, IDictionaryItemService IDictionaryItem? itemKeyCollision = _dictionaryRepository.Get(dictionaryItem.ItemKey); return itemKeyCollision != null && itemKeyCollision.Key != dictionaryItem.Key; } - - private ILanguage? GetLanguageById(int id) => _languageService.GetAllAsync().GetAwaiter().GetResult().FirstOrDefault(l => l.Id == id); } diff --git a/src/Umbraco.Core/Services/EntityXmlSerializer.cs b/src/Umbraco.Core/Services/EntityXmlSerializer.cs index 6e2e130770..265ed53ee8 100644 --- a/src/Umbraco.Core/Services/EntityXmlSerializer.cs +++ b/src/Umbraco.Core/Services/EntityXmlSerializer.cs @@ -578,8 +578,7 @@ internal class EntityXmlSerializer : IEntityXmlSerializer { xml.Add(new XElement( "Value", - new XAttribute("LanguageId", translation.Language!.Id), - new XAttribute("LanguageCultureAlias", translation.Language.IsoCode), + new XAttribute("LanguageCultureAlias", translation.LanguageIsoCode), new XCData(translation.Value))); } diff --git a/src/Umbraco.Core/Services/LanguageService.cs b/src/Umbraco.Core/Services/LanguageService.cs index 604b494799..f0fff46e51 100644 --- a/src/Umbraco.Core/Services/LanguageService.cs +++ b/src/Umbraco.Core/Services/LanguageService.cs @@ -6,10 +6,11 @@ using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services; -internal class LanguageService : RepositoryService, ILanguageService +internal sealed class LanguageService : RepositoryService, ILanguageService { private readonly ILanguageRepository _languageRepository; private readonly IAuditRepository _auditRepository; @@ -148,10 +149,14 @@ internal class LanguageService : RepositoryService, ILanguageService string auditMessage, int userId) { - if (HasValidIsoCode(language) == false) + if (IsValidIsoCode(language.IsoCode) == false) { return Attempt.FailWithStatus(LanguageOperationStatus.InvalidIsoCode, language); } + if (language.FallbackIsoCode is not null && IsValidIsoCode(language.FallbackIsoCode) == false) + { + return Attempt.FailWithStatus(LanguageOperationStatus.InvalidFallbackIsoCode, language); + } using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { @@ -195,25 +200,24 @@ internal class LanguageService : RepositoryService, ILanguageService private bool HasInvalidFallbackLanguage(ILanguage language) { // no fallback language = valid - if (language.FallbackLanguageId.HasValue == false) + if (language.FallbackIsoCode == null) { return false; } // does the fallback language actually exist? - var languages = _languageRepository.GetMany().ToDictionary(x => x.Id, x => x); - if (languages.ContainsKey(language.FallbackLanguageId.Value) == false) + var languagesByIsoCode = _languageRepository.GetMany().ToDictionary(x => x.IsoCode, x => x, StringComparer.OrdinalIgnoreCase); + if (!languagesByIsoCode.ContainsKey(language.FallbackIsoCode)) { return true; } - // does the fallback language create a cycle? - if (CreatesCycle(language, languages)) + if (CreatesCycle(language, languagesByIsoCode)) { // explicitly logging this because it may not be obvious, specially with implicit cyclic fallbacks LoggerFactory .CreateLogger() - .Log(LogLevel.Error, $"Cannot use language {languages[language.FallbackLanguageId.Value].IsoCode} as fallback for language {language.IsoCode} as this would create a fallback cycle."); + .Log(LogLevel.Error, $"Cannot use language {language.FallbackIsoCode} as fallback for language {language.IsoCode} as this would create a fallback cycle."); return true; } @@ -221,7 +225,7 @@ internal class LanguageService : RepositoryService, ILanguageService return false; } - private bool CreatesCycle(ILanguage language, IDictionary languages) + private bool CreatesCycle(ILanguage language, IDictionary languagesByIsoCode) { // a new language is not referenced yet, so cannot be part of a cycle if (!language.HasIdentity) @@ -229,31 +233,31 @@ internal class LanguageService : RepositoryService, ILanguageService return false; } - var id = language.FallbackLanguageId; + var isoCode = language.FallbackIsoCode; // assuming languages does not already contains a cycle, this must end while (true) { - if (!id.HasValue) + if (isoCode == null) { return false; // no fallback means no cycle } - if (id.Value == language.Id) + if (isoCode.InvariantEquals(language.IsoCode)) { return true; // back to language = cycle! } - id = languages[id.Value].FallbackLanguageId; // else keep chaining + isoCode = languagesByIsoCode[isoCode].FallbackIsoCode; // else keep chaining } } - private static bool HasValidIsoCode(ILanguage language) + private static bool IsValidIsoCode(string isoCode) { try { - var culture = CultureInfo.GetCultureInfo(language.IsoCode); - return culture.Name == language.IsoCode; + var culture = CultureInfo.GetCultureInfo(isoCode); + return culture.Name == isoCode && culture.CultureTypes.HasFlag(CultureTypes.UserCustomCulture) == false; } catch (CultureNotFoundException) { diff --git a/src/Umbraco.Core/Services/LocalizationService.cs b/src/Umbraco.Core/Services/LocalizationService.cs index b7b26f38cc..8acb155cbc 100644 --- a/src/Umbraco.Core/Services/LocalizationService.cs +++ b/src/Umbraco.Core/Services/LocalizationService.cs @@ -126,11 +126,7 @@ internal class LocalizationService : RepositoryService, ILocalizationService { using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true)) { - IDictionaryItem? item = _dictionaryRepository.Get(id); - - // ensure the lazy Language callback is assigned - EnsureDictionaryItemLanguageCallback(item); - return item; + return _dictionaryRepository.Get(id); } } @@ -324,7 +320,7 @@ internal class LocalizationService : RepositoryService, ILocalizationService // mimic old Save behavior if (result.Status == LanguageOperationStatus.InvalidFallback) { - throw new InvalidOperationException($"Cannot save language {language.IsoCode} with fallback id={language.FallbackLanguageId}."); + throw new InvalidOperationException($"Cannot save language {language.IsoCode} with fallback {language.FallbackIsoCode}."); } } @@ -345,31 +341,4 @@ internal class LocalizationService : RepositoryService, ILocalizationService return _dictionaryRepository.GetDictionaryItemKeyMap(); } } - - /// - /// This is here to take care of a hack - the DictionaryTranslation model contains an ILanguage reference which we - /// don't want but - /// we cannot remove it because it would be a large breaking change, so we need to make sure it's resolved lazily. This - /// is because - /// if developers have a lot of dictionary items and translations, the caching and cloning size gets much larger - /// because of - /// the large object graphs. So now we don't cache or clone the attached ILanguage - /// - private void EnsureDictionaryItemLanguageCallback(IDictionaryItem? d) - { - if (d is not DictionaryItem item) - { - return; - } - - item.GetLanguage = GetLanguageById; - IEnumerable? translations = item.Translations?.OfType(); - if (translations is not null) - { - foreach (DictionaryTranslation trans in translations) - { - trans.GetLanguage = GetLanguageById; - } - } - } } diff --git a/src/Umbraco.Core/Services/OperationStatus/LanguageOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/LanguageOperationStatus.cs index 414211b3ee..b8664ab7ed 100644 --- a/src/Umbraco.Core/Services/OperationStatus/LanguageOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/LanguageOperationStatus.cs @@ -9,5 +9,6 @@ public enum LanguageOperationStatus MissingDefault, DuplicateIsoCode, InvalidIsoCode, + InvalidFallbackIsoCode, InvalidId } diff --git a/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs b/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs index 76e20a48e8..ec4f2882bd 100644 --- a/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs +++ b/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs @@ -1466,7 +1466,7 @@ namespace Umbraco.Cms.Infrastructure.Packaging private static bool DictionaryValueIsNew(IEnumerable translations, XElement valueElement) - => translations.All(t => string.Compare(t.Language?.IsoCode, + => translations.All(t => string.Compare(t.LanguageIsoCode, valueElement.Attribute("LanguageCultureAlias")?.Value, StringComparison.InvariantCultureIgnoreCase) != 0); diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/DictionaryItemFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/DictionaryItemFactory.cs index 5a82c3be01..5b4f4bc7ba 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/DictionaryItemFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/DictionaryItemFactory.cs @@ -28,40 +28,13 @@ internal static class DictionaryItemFactory } } - private static List BuildLanguageTextDtos(IDictionaryItem entity) - { - var list = new List(); - if (entity.Translations is not null) - { - foreach (IDictionaryTranslation translation in entity.Translations) - { - var text = new LanguageTextDto - { - LanguageId = translation.LanguageId, - UniqueId = translation.Key, - Value = translation.Value, - }; - - if (translation.HasIdentity) - { - text.PrimaryKey = translation.Id; - } - - list.Add(text); - } - } - - return list; - } - public static DictionaryDto BuildDto(IDictionaryItem entity) => new DictionaryDto { UniqueId = entity.Key, Key = entity.ItemKey, Parent = entity.ParentId, - PrimaryKey = entity.Id, - LanguageTextDtos = BuildLanguageTextDtos(entity), + PrimaryKey = entity.Id }; #endregion diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/DictionaryTranslationFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/DictionaryTranslationFactory.cs index a06adb5c34..1f63095875 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/DictionaryTranslationFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/DictionaryTranslationFactory.cs @@ -7,9 +7,9 @@ internal static class DictionaryTranslationFactory { #region Implementation of IEntityFactory - public static IDictionaryTranslation BuildEntity(LanguageTextDto dto, Guid uniqueId) + public static IDictionaryTranslation BuildEntity(LanguageTextDto dto, Guid uniqueId, ILanguage language) { - var item = new DictionaryTranslation(dto.LanguageId, dto.Value, uniqueId); + var item = new DictionaryTranslation(language, dto.Value, uniqueId); try { @@ -27,9 +27,14 @@ internal static class DictionaryTranslationFactory } } - public static LanguageTextDto BuildDto(IDictionaryTranslation entity, Guid uniqueId) + public static LanguageTextDto BuildDto(IDictionaryTranslation entity, Guid uniqueId, IDictionary languagesByIsoCode) { - var text = new LanguageTextDto { LanguageId = entity.LanguageId, UniqueId = uniqueId, Value = entity.Value }; + if (languagesByIsoCode.TryGetValue(entity.LanguageIsoCode, out ILanguage? language) == false) + { + throw new ArgumentException($"Could not find language with ISO code: {entity.LanguageIsoCode}", nameof(entity)); + } + + var text = new LanguageTextDto { LanguageId = language.Id, UniqueId = uniqueId, Value = entity.Value }; if (entity.HasIdentity) { diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/LanguageFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/LanguageFactory.cs index 9ab958c306..32b3892964 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/LanguageFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/LanguageFactory.cs @@ -6,7 +6,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Factories; internal static class LanguageFactory { - public static ILanguage BuildEntity(LanguageDto dto) + public static ILanguage BuildEntity(LanguageDto dto, string? fallbackIsoCode) { ArgumentNullException.ThrowIfNull(dto); if (dto.IsoCode is null) @@ -21,7 +21,7 @@ internal static class LanguageFactory Id = dto.Id, IsDefault = dto.IsDefault, IsMandatory = dto.IsMandatory, - FallbackLanguageId = dto.FallbackLanguageId, + FallbackIsoCode = fallbackIsoCode }; // Reset dirty initial properties @@ -30,7 +30,7 @@ internal static class LanguageFactory return lang; } - public static LanguageDto BuildDto(ILanguage entity) + public static LanguageDto BuildDto(ILanguage entity, int? fallbackLanguageId) { ArgumentNullException.ThrowIfNull(entity); @@ -40,7 +40,7 @@ internal static class LanguageFactory CultureName = entity.CultureName, IsDefault = entity.IsDefault, IsMandatory = entity.IsMandatory, - FallbackLanguageId = entity.FallbackLanguageId, + FallbackLanguageId = fallbackLanguageId }; if (entity.HasIdentity) diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/DictionaryTranslationMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/DictionaryTranslationMapper.cs index eba2563835..b80fb6db9a 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/DictionaryTranslationMapper.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/DictionaryTranslationMapper.cs @@ -24,9 +24,6 @@ public sealed class DictionaryTranslationMapper : BaseMapper DefineMap( nameof(DictionaryTranslation.Key), nameof(LanguageTextDto.UniqueId)); - DefineMap( - nameof(DictionaryTranslation.Language), - nameof(LanguageTextDto.LanguageId)); DefineMap( nameof(DictionaryTranslation.Value), nameof(LanguageTextDto.Value)); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs index 909c9cfec2..13f621b14b 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs @@ -20,11 +20,15 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; internal class DictionaryRepository : EntityRepositoryBase, IDictionaryRepository { private readonly ILoggerFactory _loggerFactory; + private readonly ILanguageRepository _languageRepository; public DictionaryRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger, - ILoggerFactory loggerFactory) - : base(scopeAccessor, cache, logger) => + ILoggerFactory loggerFactory, ILanguageRepository languageRepository) + : base(scopeAccessor, cache, logger) + { _loggerFactory = loggerFactory; + _languageRepository = languageRepository; + } public IDictionaryItem? Get(Guid uniqueId) { @@ -63,6 +67,8 @@ internal class DictionaryRepository : EntityRepositoryBase public IEnumerable GetDictionaryItemDescendants(Guid? parentId) { + IDictionary languageIsoCodeById = GetLanguagesById(); + // This methods will look up children at each level, since we do not store a path for dictionary (ATM), we need to do a recursive // lookup to get descendants. Currently this is the most efficient way to do it Func>> getItemsFromParents = guids => @@ -80,7 +86,7 @@ internal class DictionaryRepository : EntityRepositoryBase return Database .FetchOneToMany(x => x.LanguageTextDtos, sql) - .Select(ConvertFromDto); + .Select(dto => ConvertFromDto(dto, languageIsoCodeById)); }); }; @@ -91,7 +97,7 @@ internal class DictionaryRepository : EntityRepositoryBase .OrderBy(x => x.UniqueId); return Database .FetchOneToMany(x => x.LanguageTextDtos, sql) - .Select(ConvertFromDto); + .Select(dto => ConvertFromDto(dto, languageIsoCodeById)); } return getItemsFromParents(new[] { parentId.Value }).SelectRecursive(items => getItemsFromParents(items.Select(x => x.Key).ToArray())).SelectMany(items => items); @@ -109,13 +115,16 @@ internal class DictionaryRepository : EntityRepositoryBase options); } - protected IDictionaryItem ConvertFromDto(DictionaryDto dto) + private IDictionaryItem ConvertFromDto(DictionaryDto dto, IDictionary languagesById) { IDictionaryItem entity = DictionaryItemFactory.BuildEntity(dto); entity.Translations = dto.LanguageTextDtos.EmptyNull() .Where(x => x.LanguageId > 0) - .Select(x => DictionaryTranslationFactory.BuildEntity(x, dto.UniqueId)) + .Select(x => languagesById.TryGetValue(x.LanguageId, out ILanguage? language) + ? DictionaryTranslationFactory.BuildEntity(x, dto.UniqueId, language) + : null) + .WhereNotNull() .ToList(); return entity; @@ -138,7 +147,7 @@ internal class DictionaryRepository : EntityRepositoryBase return null; } - IDictionaryItem entity = ConvertFromDto(dto); + IDictionaryItem entity = ConvertFromDto(dto, GetLanguagesById()); // reset dirty initial properties (U4-1946) ((EntityBase)entity).ResetDirtyProperties(false); @@ -162,11 +171,15 @@ internal class DictionaryRepository : EntityRepositoryBase private class DictionaryByUniqueIdRepository : SimpleGetRepository { private readonly DictionaryRepository _dictionaryRepository; + private readonly IDictionary _languagesById; public DictionaryByUniqueIdRepository(DictionaryRepository dictionaryRepository, IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) - : base(scopeAccessor, cache, logger) => + : base(scopeAccessor, cache, logger) + { _dictionaryRepository = dictionaryRepository; + _languagesById = dictionaryRepository.GetLanguagesById(); + } protected override IEnumerable PerformFetch(Sql sql) => Database @@ -178,7 +191,7 @@ internal class DictionaryRepository : EntityRepositoryBase "cmsDictionary." + SqlSyntax.GetQuotedColumnName("id") + " = @id"; protected override IDictionaryItem ConvertToEntity(DictionaryDto dto) => - _dictionaryRepository.ConvertFromDto(dto); + _dictionaryRepository.ConvertFromDto(dto, _languagesById); protected override object GetBaseWhereClauseArguments(Guid id) => new { id }; @@ -201,11 +214,15 @@ internal class DictionaryRepository : EntityRepositoryBase private class DictionaryByKeyRepository : SimpleGetRepository { private readonly DictionaryRepository _dictionaryRepository; + private readonly IDictionary _languagesById; public DictionaryByKeyRepository(DictionaryRepository dictionaryRepository, IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) - : base(scopeAccessor, cache, logger) => + : base(scopeAccessor, cache, logger) + { _dictionaryRepository = dictionaryRepository; + _languagesById = dictionaryRepository.GetLanguagesById(); + } protected override IEnumerable PerformFetch(Sql sql) => Database @@ -217,7 +234,7 @@ internal class DictionaryRepository : EntityRepositoryBase "cmsDictionary." + SqlSyntax.GetQuotedColumnName("key") + " = @id"; protected override IDictionaryItem ConvertToEntity(DictionaryDto dto) => - _dictionaryRepository.ConvertFromDto(dto); + _dictionaryRepository.ConvertFromDto(dto, _languagesById); protected override object GetBaseWhereClauseArguments(string? id) => new { id }; @@ -245,9 +262,11 @@ internal class DictionaryRepository : EntityRepositoryBase sql.WhereIn(x => x.PrimaryKey, ids); } + IDictionary languageIsoCodeById = GetLanguagesById(); + return Database .FetchOneToMany(x => x.LanguageTextDtos, sql) - .Select(ConvertFromDto); + .Select(dto => ConvertFromDto(dto, languageIsoCodeById)); } protected override IEnumerable PerformGetByQuery(IQuery query) @@ -257,9 +276,11 @@ internal class DictionaryRepository : EntityRepositoryBase Sql sql = translator.Translate(); sql.OrderBy(x => x.UniqueId); + IDictionary languageIsoCodeById = GetLanguagesById(); + return Database .FetchOneToMany(x => x.LanguageTextDtos, sql) - .Select(ConvertFromDto); + .Select(dto => ConvertFromDto(dto, languageIsoCodeById)); } #endregion @@ -309,9 +330,11 @@ internal class DictionaryRepository : EntityRepositoryBase var id = Convert.ToInt32(Database.Insert(dto)); dictionaryItem.Id = id; + IDictionary languagesByIsoCode = GetLanguagesByIsoCode(); + foreach (IDictionaryTranslation translation in dictionaryItem.Translations) { - LanguageTextDto textDto = DictionaryTranslationFactory.BuildDto(translation, dictionaryItem.Key); + LanguageTextDto textDto = DictionaryTranslationFactory.BuildDto(translation, dictionaryItem.Key, languagesByIsoCode); translation.Id = Convert.ToInt32(Database.Insert(textDto)); translation.Key = dictionaryItem.Key; } @@ -332,9 +355,11 @@ internal class DictionaryRepository : EntityRepositoryBase Database.Update(dto); + IDictionary languagesByIsoCode = GetLanguagesByIsoCode(); + foreach (IDictionaryTranslation translation in entity.Translations) { - LanguageTextDto textDto = DictionaryTranslationFactory.BuildDto(translation, entity.Key); + LanguageTextDto textDto = DictionaryTranslationFactory.BuildDto(translation, entity.Key, languagesByIsoCode); if (translation.HasIdentity) { Database.Update(textDto); @@ -384,5 +409,13 @@ internal class DictionaryRepository : EntityRepositoryBase } } + private IDictionary GetLanguagesById() => _languageRepository + .GetMany() + .ToDictionary(language => language.Id); + + private IDictionary GetLanguagesByIsoCode() => _languageRepository + .GetMany() + .ToDictionary(language => language.IsoCode); + #endregion } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs index 398a55ebaf..e6500b49b7 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs @@ -31,11 +31,7 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu public ILanguage? GetByIsoCode(string isoCode) { - // ensure cache is populated, in a non-expensive way - if (TypedCachePolicy != null) - { - TypedCachePolicy.GetAllCached(PerformGetAll); - } + EnsureCacheIsPopulated(); var id = GetIdByIsoCode(isoCode, false); return id.HasValue ? Get(id.Value) : null; @@ -50,15 +46,7 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu return null; } - // ensure cache is populated, in a non-expensive way - if (TypedCachePolicy != null) - { - TypedCachePolicy.GetAllCached(PerformGetAll); - } - else - { - PerformGetAll(); // We don't have a typed cache (i.e. unit tests) but need to populate the _codeIdMap - } + EnsureCacheIsPopulated(); lock (_codeIdMap) { @@ -85,15 +73,7 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu return null; } - // ensure cache is populated, in a non-expensive way - if (TypedCachePolicy != null) - { - TypedCachePolicy.GetAllCached(PerformGetAll); - } - else - { - PerformGetAll(); - } + EnsureCacheIsPopulated(); // yes, we want to lock _codeIdMap lock (_codeIdMap) @@ -120,7 +100,19 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu new FullDataSetRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, GetEntityId, /*expires:*/ false); protected ILanguage ConvertFromDto(LanguageDto dto) - => LanguageFactory.BuildEntity(dto); + { + // yes, we want to lock _codeIdMap + lock (_codeIdMap) + { + string? fallbackIsoCode = null; + if (dto.FallbackLanguageId.HasValue && _idCodeMap.TryGetValue(dto.FallbackLanguageId.Value, out fallbackIsoCode) == false) + { + throw new ArgumentException($"The ISO code map did not contain ISO code for fallback language ID: {dto.FallbackLanguageId}. Please reload the caches."); + } + + return LanguageFactory.BuildEntity(dto, fallbackIsoCode); + } + } // do NOT leak that language, it's not deep-cloned! private ILanguage GetDefault() @@ -172,20 +164,25 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu sql.OrderBy(x => x.Id); // get languages - var languages = Database.Fetch(sql).Select(ConvertFromDto).OrderBy(x => x.Id).ToList(); + List? languageDtos = Database.Fetch(sql) ?? new List(); - // initialize the code-id map - lock (_codeIdMap) + // initialize the code-id map if we've reloaded the entire set of languages + if (ids?.Any() == false) { - _codeIdMap.Clear(); - _idCodeMap.Clear(); - foreach (ILanguage language in languages) + lock (_codeIdMap) { - _codeIdMap[language.IsoCode] = language.Id; - _idCodeMap[language.Id] = language.IsoCode.ToLowerInvariant(); + _codeIdMap.Clear(); + _idCodeMap.Clear(); + foreach (LanguageDto languageDto in languageDtos) + { + ArgumentException.ThrowIfNullOrEmpty(languageDto.IsoCode, nameof(LanguageDto.IsoCode)); + _codeIdMap[languageDto.IsoCode] = languageDto.Id; + _idCodeMap[languageDto.Id] = languageDto.IsoCode; + } } } + var languages = languageDtos.Select(ConvertFromDto).OrderBy(x => x.Id).ToList(); return languages; } @@ -247,6 +244,8 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu throw new InvalidOperationException("Cannot save a language without an ISO code and a culture name."); } + EnsureCacheIsPopulated(); + entity.AddingEntity(); // deal with entity becoming the new default entity @@ -262,10 +261,17 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu // fallback cycles are detected at service level // insert - LanguageDto dto = LanguageFactory.BuildDto(entity); + LanguageDto dto = LanguageFactory.BuildDto(entity, GetFallbackLanguageId(entity)); var id = Convert.ToInt32(Database.Insert(dto)); entity.Id = id; entity.ResetDirtyProperties(); + + // yes, we want to lock _codeIdMap + lock (_codeIdMap) + { + _codeIdMap[entity.IsoCode] = entity.Id; + _idCodeMap[entity.Id] = entity.IsoCode; + } } protected override void PersistUpdatedItem(ILanguage entity) @@ -276,6 +282,8 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu throw new InvalidOperationException("Cannot save a language without an ISO code and a culture name."); } + EnsureCacheIsPopulated(); + entity.UpdatingEntity(); if (entity.IsDefault) @@ -324,9 +332,17 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu // fallback cycles are detected at service level // update - LanguageDto dto = LanguageFactory.BuildDto(entity); + LanguageDto dto = LanguageFactory.BuildDto(entity, GetFallbackLanguageId(entity)); Database.Update(dto); entity.ResetDirtyProperties(); + + // yes, we want to lock _codeIdMap + lock (_codeIdMap) + { + _codeIdMap.RemoveAll(kvp => kvp.Value == entity.Id); + _codeIdMap[entity.IsoCode] = entity.Id; + _idCodeMap[entity.Id] = entity.IsoCode; + } } protected override void PersistDeletedItem(ILanguage entity) @@ -354,6 +370,38 @@ internal class LanguageRepository : EntityRepositoryBase, ILangu // delete base.PersistDeletedItem(entity); + + // yes, we want to lock _codeIdMap + lock (_codeIdMap) + { + _codeIdMap.RemoveAll(kvp => kvp.Value == entity.Id); + _idCodeMap.Remove(entity.Id); + } + } + + private void EnsureCacheIsPopulated() + { + // ensure cache is populated, in a non-expensive way + if (TypedCachePolicy != null) + { + TypedCachePolicy.GetAllCached(PerformGetAll); + } + else + { + PerformGetAll(); // We don't have a typed cache (i.e. unit tests) but need to populate the _codeIdMap + } + } + + private int? GetFallbackLanguageId(ILanguage entity) + { + int? fallbackLanguageId = null; + if (entity.FallbackIsoCode.IsNullOrWhiteSpace() == false && + _codeIdMap.TryGetValue(entity.FallbackIsoCode, out var languageId)) + { + fallbackLanguageId = languageId; + } + + return fallbackLanguageId; } #endregion diff --git a/src/Umbraco.Web.BackOffice/Controllers/LanguageController.cs b/src/Umbraco.Web.BackOffice/Controllers/LanguageController.cs index 4cb7dc52fc..c058a39ef4 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/LanguageController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/LanguageController.cs @@ -139,7 +139,7 @@ public class LanguageController : UmbracoAuthorizedJsonController { IsDefault = language.IsDefault, IsMandatory = language.IsMandatory, - FallbackLanguageId = language.FallbackLanguageId + FallbackIsoCode = language.FallbackIsoCode }; _localizationService.Save(newLang); @@ -162,14 +162,14 @@ public class LanguageController : UmbracoAuthorizedJsonController existingById.IsDefault = language.IsDefault; existingById.IsMandatory = language.IsMandatory; - existingById.FallbackLanguageId = language.FallbackLanguageId; + existingById.FallbackIsoCode = language.FallbackIsoCode; // modifying an existing language can create a fallback, verify // note that the service will check again, dealing with race conditions - if (existingById.FallbackLanguageId.HasValue) + if (existingById.FallbackIsoCode != null) { - var languages = _localizationService.GetAllLanguages().ToDictionary(x => x.Id, x => x); - if (!languages.ContainsKey(existingById.FallbackLanguageId.Value)) + var languages = _localizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x); + if (!languages.ContainsKey(existingById.FallbackIsoCode)) { ModelState.AddModelError("FallbackLanguage", "The selected fall back language does not exist."); return ValidationProblem(ModelState); @@ -178,7 +178,7 @@ public class LanguageController : UmbracoAuthorizedJsonController if (CreatesCycle(existingById, languages)) { ModelState.AddModelError("FallbackLanguage", - $"The selected fall back language {languages[existingById.FallbackLanguageId.Value].IsoCode} would create a circular path."); + $"The selected fall back language {existingById.FallbackIsoCode} would create a circular path."); return ValidationProblem(ModelState); } } @@ -188,7 +188,7 @@ public class LanguageController : UmbracoAuthorizedJsonController } // see LocalizationService - private bool CreatesCycle(ILanguage language, IDictionary languages) + private bool CreatesCycle(ILanguage language, IDictionary languagesByIsoCode) { // a new language is not referenced yet, so cannot be part of a cycle if (!language.HasIdentity) @@ -196,20 +196,20 @@ public class LanguageController : UmbracoAuthorizedJsonController return false; } - var id = language.FallbackLanguageId; + var isoCode = language.FallbackIsoCode; while (true) // assuming languages does not already contains a cycle, this must end { - if (!id.HasValue) + if (isoCode == null) { return false; // no fallback means no cycle } - if (id.Value == language.Id) + if (isoCode == language.IsoCode) { return true; // back to language = cycle! } - id = languages[id.Value].FallbackLanguageId; // else keep chaining + isoCode = languagesByIsoCode[isoCode].FallbackIsoCode; // else keep chaining } } } diff --git a/tests/Umbraco.Tests.Common/Builders/LanguageBuilder.cs b/tests/Umbraco.Tests.Common/Builders/LanguageBuilder.cs index 0eaf125332..61504bf069 100644 --- a/tests/Umbraco.Tests.Common/Builders/LanguageBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/LanguageBuilder.cs @@ -29,7 +29,7 @@ public class LanguageBuilder private CultureInfo _cultureInfo; private string _cultureName; private DateTime? _deleteDate; - private int? _fallbackLanguageId; + private string? _fallbackLanguageIsoCode; private int? _id; private bool? _isDefault; private bool? _isMandatory; @@ -95,9 +95,9 @@ public class LanguageBuilder return this; } - public LanguageBuilder WithFallbackLanguageId(int fallbackLanguageId) + public LanguageBuilder WithFallbackLanguageIsoCode(string fallbackLanguageIsoCode) { - _fallbackLanguageId = fallbackLanguageId; + _fallbackLanguageIsoCode = fallbackLanguageIsoCode; return this; } @@ -109,7 +109,7 @@ public class LanguageBuilder var createDate = _createDate ?? DateTime.Now; var updateDate = _updateDate ?? DateTime.Now; var deleteDate = _deleteDate; - var fallbackLanguageId = _fallbackLanguageId; + var fallbackLanguageIsoCode = _fallbackLanguageIsoCode; var isDefault = _isDefault ?? false; var isMandatory = _isMandatory ?? false; @@ -122,7 +122,7 @@ public class LanguageBuilder DeleteDate = deleteDate, IsDefault = isDefault, IsMandatory = isMandatory, - FallbackLanguageId = fallbackLanguageId + FallbackIsoCode = fallbackLanguageIsoCode }; } } diff --git a/tests/Umbraco.Tests.Common/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Common/CompatibilitySuppressions.xml new file mode 100644 index 0000000000..657f05d2d7 --- /dev/null +++ b/tests/Umbraco.Tests.Common/CompatibilitySuppressions.xml @@ -0,0 +1,10 @@ + + + + CP0002 + M:Umbraco.Cms.Tests.Common.Builders.LanguageBuilder`1.WithFallbackLanguageId(System.Int32) + lib/net7.0/Umbraco.Tests.Common.dll + lib/net7.0/Umbraco.Tests.Common.dll + true + + \ No newline at end of file diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs index 7f9dcf5175..4e88998c09 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/TelemetryProviderTests.cs @@ -132,10 +132,12 @@ public class TelemetryProviderTests : UmbracoIntegrationTest { // Arrange var langTwo = _languageBuilder.WithCultureInfo("da-DK").Build(); - var langThree = _languageBuilder.WithCultureInfo("se-SV").Build(); + var langThree = _languageBuilder.WithCultureInfo("sv-SE").Build(); - await LanguageService.CreateAsync(langTwo); - await LanguageService.CreateAsync(langThree); + var langTwoResult = await LanguageService.CreateAsync(langTwo); + Assert.IsTrue(langTwoResult.Success); + var langThreeResult = await LanguageService.CreateAsync(langThree); + Assert.IsTrue(langThreeResult.Success); IEnumerable result = null; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs index 5b08214d8e..9172b53dac 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Packaging/PackageDataInstallationTests.cs @@ -864,7 +864,7 @@ public class PackageDataInstallationTests : UmbracoIntegrationTestWithContent { Assert.That(await DictionaryItemService.ExistsAsync(dictionaryItemName), "DictionaryItem key does not exist"); var dictionaryItem = await DictionaryItemService.GetAsync(dictionaryItemName); - var translation = dictionaryItem.Translations.SingleOrDefault(i => i.Language.IsoCode == cultureCode); + var translation = dictionaryItem.Translations.SingleOrDefault(i => i.LanguageIsoCode == cultureCode); Assert.IsNotNull(translation, "Translation to {0} was not added", cultureCode); var value = translation.Value; Assert.That(value, Is.EqualTo(expectedValue), "Translation value was not set"); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DictionaryRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DictionaryRepositoryTest.cs index e10a104100..b4ced5d768 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DictionaryRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/DictionaryRepositoryTest.cs @@ -332,7 +332,7 @@ public class DictionaryRepositoryTest : UmbracoIntegrationTest // Assert Assert.That(dictionaryItem, Is.Not.Null); Assert.That(dictionaryItem.Translations.Count(), Is.EqualTo(3)); - Assert.That(dictionaryItem.Translations.Single(t => t.LanguageId == languageNo.Id).Value, Is.EqualTo("Les mer")); + Assert.That(dictionaryItem.Translations.Single(t => t.LanguageIsoCode == languageNo.IsoCode).Value, Is.EqualTo("Les mer")); } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/LanguageRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/LanguageRepositoryTest.cs index 703829c351..3823103f7e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/LanguageRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/LanguageRepositoryTest.cs @@ -42,7 +42,7 @@ public class LanguageRepositoryTest : UmbracoIntegrationTest Assert.That(language.HasIdentity, Is.True); Assert.That(language.CultureName, Is.EqualTo("English (United States)")); Assert.That(language.IsoCode, Is.EqualTo("en-US")); - Assert.That(language.FallbackLanguageId, Is.Null); + Assert.That(language.FallbackIsoCode, Is.Null); } } @@ -55,7 +55,7 @@ public class LanguageRepositoryTest : UmbracoIntegrationTest var repository = CreateRepository(provider); var au = CultureInfo.GetCultureInfo("en-AU"); - ILanguage language = new Language(au.Name, au.EnglishName) { FallbackLanguageId = 1 }; + ILanguage language = new Language(au.Name, au.EnglishName) { FallbackIsoCode = "en-US" }; repository.Save(language); // re-get @@ -66,7 +66,7 @@ public class LanguageRepositoryTest : UmbracoIntegrationTest Assert.That(language.HasIdentity, Is.True); Assert.That(language.CultureName, Is.EqualTo(au.EnglishName)); Assert.That(language.IsoCode, Is.EqualTo(au.Name)); - Assert.That(language.FallbackLanguageId, Is.EqualTo(1)); + Assert.That(language.FallbackIsoCode, Is.EqualTo("en-US")); } } @@ -183,7 +183,7 @@ public class LanguageRepositoryTest : UmbracoIntegrationTest Assert.That(languageBR.Id, Is.EqualTo(6)); // With 5 existing entries the Id should be 6 Assert.IsFalse(languageBR.IsDefault); Assert.IsFalse(languageBR.IsMandatory); - Assert.IsNull(languageBR.FallbackLanguageId); + Assert.IsNull(languageBR.FallbackIsoCode); } } @@ -205,7 +205,7 @@ public class LanguageRepositoryTest : UmbracoIntegrationTest Assert.That(languageBR.Id, Is.EqualTo(6)); // With 5 existing entries the Id should be 6 Assert.IsTrue(languageBR.IsDefault); Assert.IsTrue(languageBR.IsMandatory); - Assert.IsNull(languageBR.FallbackLanguageId); + Assert.IsNull(languageBR.FallbackIsoCode); } } @@ -219,13 +219,13 @@ public class LanguageRepositoryTest : UmbracoIntegrationTest var repository = CreateRepository(provider); // Act - var languageBR = new Language("pt-BR", "Portuguese (Brazil)") { FallbackLanguageId = 1 }; + var languageBR = new Language("pt-BR", "Portuguese (Brazil)") { FallbackIsoCode = "en-US" }; repository.Save(languageBR); // Assert Assert.That(languageBR.HasIdentity, Is.True); Assert.That(languageBR.Id, Is.EqualTo(6)); // With 5 existing entries the Id should be 6 - Assert.That(languageBR.FallbackLanguageId, Is.EqualTo(1)); + Assert.That(languageBR.FallbackIsoCode, Is.EqualTo("en-US")); } } @@ -270,7 +270,7 @@ public class LanguageRepositoryTest : UmbracoIntegrationTest var language = repository.Get(5); language.IsoCode = "pt-BR"; language.CultureName = "Portuguese (Brazil)"; - language.FallbackLanguageId = 1; + language.FallbackIsoCode = "en-US"; repository.Save(language); @@ -280,7 +280,7 @@ public class LanguageRepositoryTest : UmbracoIntegrationTest Assert.That(languageUpdated, Is.Not.Null); Assert.That(languageUpdated.IsoCode, Is.EqualTo("pt-BR")); Assert.That(languageUpdated.CultureName, Is.EqualTo("Portuguese (Brazil)")); - Assert.That(languageUpdated.FallbackLanguageId, Is.EqualTo(1)); + Assert.That(languageUpdated.FallbackIsoCode, Is.EqualTo("en-US")); } } @@ -332,7 +332,7 @@ public class LanguageRepositoryTest : UmbracoIntegrationTest // Add language to delete as a fall-back language to another one var repository = CreateRepository(provider); var languageToFallbackFrom = repository.Get(5); - languageToFallbackFrom.FallbackLanguageId = 2; // fall back to #2 (something we can delete) + languageToFallbackFrom.FallbackIsoCode = "da-DK"; // fall back to "da-DK" (something we can delete) repository.Save(languageToFallbackFrom); // delete #2 diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Scoping/ScopedRepositoryTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Scoping/ScopedRepositoryTests.cs index ce17a40685..80fa416da0 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Scoping/ScopedRepositoryTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Scoping/ScopedRepositoryTests.cs @@ -246,7 +246,7 @@ public class ScopedRepositoryTests : UmbracoIntegrationTest var item = (await dictionaryItemService.CreateAsync( new DictionaryItem("item-key") { - Translations = new IDictionaryTranslation[] { new DictionaryTranslation(lang.Id, "item-value") } + Translations = new IDictionaryTranslation[] { new DictionaryTranslation(lang, "item-value") } })).Result; // Refresh the keyed cache manually diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DictionaryItemServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DictionaryItemServiceTests.cs index 0af6220eb7..c59260f205 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DictionaryItemServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/DictionaryItemServiceTests.cs @@ -200,7 +200,7 @@ public class DictionaryItemServiceTests : UmbracoIntegrationTest foreach (var language in allLangs) { Assert.AreEqual($"Translation for: {language.IsoCode}", - item.Translations.Single(x => x.Language.CultureName == language.CultureName).Value); + item.Translations.Single(x => x.LanguageIsoCode == language.IsoCode).Value); } } @@ -229,7 +229,7 @@ public class DictionaryItemServiceTests : UmbracoIntegrationTest Assert.IsFalse(item.ParentId.HasValue); Assert.AreEqual("Testing12345", item.ItemKey); Assert.AreEqual(1, item.Translations.Count()); - Assert.AreEqual(firstLanguage.Id, item.Translations.First().LanguageId); + Assert.AreEqual(firstLanguage.IsoCode, item.Translations.First().LanguageIsoCode); } [Test] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/Importing/Dictionary-Package.xml b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/Importing/Dictionary-Package.xml index 6920e27040..53c2073eb6 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/Importing/Dictionary-Package.xml +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/Importing/Dictionary-Package.xml @@ -8,11 +8,11 @@ - - + + - - + + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/Importing/ImportResources.Designer.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/Importing/ImportResources.Designer.cs index 96deac66bd..e7153912a9 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/Importing/ImportResources.Designer.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/Importing/ImportResources.Designer.cs @@ -1,7 +1,6 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -129,9 +128,10 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.Importin /// </info> /// <DictionaryItems> /// <DictionaryItem Key="28f2e02a-8c66-4fcd-85e3-8524d551c0d3" Name="Parent"> - /// <Value LanguageId="2" LanguageCultureAlias="nb-NO"><![CDATA[ForelderVerdi]]></Value> - /// <Value LanguageId="3" LanguageCultureAlias="en-GB"><![CDATA[ParentValue]]></Value> - /// <DictionaryItem Key="e7dba0a9-d517-4ba4-8e18-2764d392c611" Name=" [rest of string was truncated]";. + /// <Value LanguageCultureAlias="nb-NO"><![CDATA[ForelderVerdi]]></Value> + /// <Value LanguageCultureAlias="en-GB"><![CDATA[ParentValue]]></Value> + /// <DictionaryItem Key="e7dba0a9-d517-4ba4-8e18-2764d392c611" Name="Child"> + /// <Value Langua [rest of string was truncated]";. /// internal static string Dictionary_Package { get { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LanguageServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LanguageServiceTests.cs index dca0f82535..3970177f54 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LanguageServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LanguageServiceTests.cs @@ -62,21 +62,48 @@ public class LanguageServiceTests : UmbracoIntegrationTest Assert.Null(language); } + [Test] + public async Task Can_Create_Language_With_Fallback() + { + var languageDaDk = await LanguageService.GetAsync("da-DK"); + Assert.NotNull(languageDaDk); + var languageNbNo = new LanguageBuilder() + .WithCultureInfo("nb-NO") + .WithFallbackLanguageIsoCode(languageDaDk.IsoCode) + .Build(); + var result = await LanguageService.CreateAsync(languageNbNo, 0); + Assert.IsTrue(result.Success); + + var language = await LanguageService.GetAsync("nb-NO"); + Assert.NotNull(language); + Assert.AreEqual("da-DK", language.FallbackIsoCode); + } + [Test] public async Task Can_Delete_Language_Used_As_Fallback() { var languageDaDk = await LanguageService.GetAsync("da-DK"); + Assert.NotNull(languageDaDk); var languageNbNo = new LanguageBuilder() .WithCultureInfo("nb-NO") - .WithFallbackLanguageId(languageDaDk.Id) + .WithFallbackLanguageIsoCode(languageDaDk.IsoCode) .Build(); - await LanguageService.CreateAsync(languageNbNo, 0); - - var result = await LanguageService.DeleteAsync("da-DK"); + var result = await LanguageService.CreateAsync(languageNbNo, 0); Assert.IsTrue(result.Success); - var language = await LanguageService.GetAsync("da-DK"); + var language = await LanguageService.GetAsync("nb-NO"); + Assert.NotNull(language); + Assert.AreEqual("da-DK", language.FallbackIsoCode); + + result = await LanguageService.DeleteAsync("da-DK"); + Assert.IsTrue(result.Success); + + language = await LanguageService.GetAsync("da-DK"); Assert.Null(language); + + language = await LanguageService.GetAsync("nb-NO"); + Assert.NotNull(language); + Assert.Null(language.FallbackIsoCode); } [Test] @@ -222,13 +249,26 @@ public class LanguageServiceTests : UmbracoIntegrationTest Assert.AreEqual(LanguageOperationStatus.DuplicateIsoCode, result.Status); } + [Test] + public async Task Cannot_Create_Language_With_Invalid_Fallback_Language() + { + var isoCode = "en-AU"; + var languageEnAu = new LanguageBuilder() + .WithCultureInfo(isoCode) + .WithFallbackLanguageIsoCode("no-such-ISO-code") + .Build(); + var result = await LanguageService.CreateAsync(languageEnAu); + Assert.IsFalse(result.Success); + Assert.AreEqual(LanguageOperationStatus.InvalidFallbackIsoCode, result.Status); + } + [Test] public async Task Cannot_Create_Language_With_NonExisting_Fallback_Language() { var isoCode = "en-AU"; var languageEnAu = new LanguageBuilder() .WithCultureInfo(isoCode) - .WithFallbackLanguageId(123456789) + .WithFallbackLanguageIsoCode("fr-FR") .Build(); var result = await LanguageService.CreateAsync(languageEnAu); Assert.IsFalse(result.Success); @@ -271,14 +311,27 @@ public class LanguageServiceTests : UmbracoIntegrationTest { ILanguage languageDaDk = await LanguageService.GetAsync("da-DK"); Assert.NotNull(languageDaDk); - Assert.IsNull(languageDaDk.FallbackLanguageId); + Assert.IsNull(languageDaDk.FallbackIsoCode); - languageDaDk.FallbackLanguageId = 123456789; + languageDaDk.FallbackIsoCode = "fr-FR"; var result = await LanguageService.UpdateAsync(languageDaDk); Assert.IsFalse(result.Success); Assert.AreEqual(LanguageOperationStatus.InvalidFallback, result.Status); } + [Test] + public async Task Cannot_Update_Language_With_Invalid_Fallback_Language() + { + ILanguage languageDaDk = await LanguageService.GetAsync("da-DK"); + Assert.NotNull(languageDaDk); + Assert.IsNull(languageDaDk.FallbackIsoCode); + + languageDaDk.FallbackIsoCode = "no-such-iso-code"; + var result = await LanguageService.UpdateAsync(languageDaDk); + Assert.IsFalse(result.Success); + Assert.AreEqual(LanguageOperationStatus.InvalidFallbackIsoCode, result.Status); + } + [Test] public async Task Cannot_Create_Direct_Cyclic_Fallback_Language() { @@ -286,14 +339,14 @@ public class LanguageServiceTests : UmbracoIntegrationTest ILanguage languageEnGb = await LanguageService.GetAsync("en-GB"); Assert.NotNull(languageDaDk); Assert.NotNull(languageEnGb); - Assert.IsNull(languageDaDk.FallbackLanguageId); - Assert.IsNull(languageEnGb.FallbackLanguageId); + Assert.IsNull(languageDaDk.FallbackIsoCode); + Assert.IsNull(languageEnGb.FallbackIsoCode); - languageDaDk.FallbackLanguageId = languageEnGb.Id; + languageDaDk.FallbackIsoCode = languageEnGb.IsoCode; var result = await LanguageService.UpdateAsync(languageDaDk); Assert.IsTrue(result.Success); - languageEnGb.FallbackLanguageId = languageDaDk.Id; + languageEnGb.FallbackIsoCode = languageDaDk.IsoCode; result = await LanguageService.UpdateAsync(languageEnGb); Assert.IsFalse(result.Success); Assert.AreEqual(LanguageOperationStatus.InvalidFallback, result.Status); @@ -308,19 +361,19 @@ public class LanguageServiceTests : UmbracoIntegrationTest Assert.IsNotNull(languageEnUs); Assert.IsNotNull(languageEnGb); Assert.IsNotNull(languageDaDk); - Assert.IsNull(languageEnUs.FallbackLanguageId); - Assert.IsNull(languageEnGb.FallbackLanguageId); - Assert.IsNull(languageDaDk.FallbackLanguageId); + Assert.IsNull(languageEnUs.FallbackIsoCode); + Assert.IsNull(languageEnGb.FallbackIsoCode); + Assert.IsNull(languageDaDk.FallbackIsoCode); - languageEnGb.FallbackLanguageId = languageEnUs.Id; + languageEnGb.FallbackIsoCode = languageEnUs.IsoCode; var result = await LanguageService.UpdateAsync(languageEnGb); Assert.IsTrue(result.Success); - languageDaDk.FallbackLanguageId = languageEnGb.Id; + languageDaDk.FallbackIsoCode = languageEnGb.IsoCode; result = await LanguageService.UpdateAsync(languageDaDk); Assert.IsTrue(result.Success); - languageEnUs.FallbackLanguageId = languageDaDk.Id; + languageEnUs.FallbackIsoCode = languageDaDk.IsoCode; result = await LanguageService.UpdateAsync(languageEnUs); Assert.IsFalse(result.Success); Assert.AreEqual(LanguageOperationStatus.InvalidFallback, result.Status); @@ -333,9 +386,9 @@ public class LanguageServiceTests : UmbracoIntegrationTest Assert.IsNotNull(languageEnGb); Assert.IsNotNull(languageDaDk); - Assert.AreEqual(languageEnUs.Id, languageEnGb.FallbackLanguageId); - Assert.AreEqual(languageEnGb.Id, languageDaDk.FallbackLanguageId); - Assert.IsNull(languageEnUs.FallbackLanguageId); + Assert.AreEqual(languageEnUs.IsoCode, languageEnGb.FallbackIsoCode); + Assert.AreEqual(languageEnGb.IsoCode, languageDaDk.FallbackIsoCode); + Assert.IsNull(languageEnUs.FallbackIsoCode); } [Test] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LocalizationServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LocalizationServiceTests.cs index 5b3eee4965..8c5e8c371f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LocalizationServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/LocalizationServiceTests.cs @@ -223,7 +223,7 @@ public class LocalizationServiceTests : UmbracoIntegrationTest var languageDaDk = LocalizationService.GetLanguageByIsoCode("da-DK"); var languageNbNo = new LanguageBuilder() .WithCultureInfo("nb-NO") - .WithFallbackLanguageId(languageDaDk.Id) + .WithFallbackLanguageIsoCode(languageDaDk.IsoCode) .Build(); LocalizationService.Save(languageNbNo, 0); var languageId = languageDaDk.Id; @@ -274,7 +274,7 @@ public class LocalizationServiceTests : UmbracoIntegrationTest foreach (var language in allLangs) { Assert.AreEqual("Hellooooo", - item.Translations.Single(x => x.Language.CultureName == language.CultureName).Value); + item.Translations.Single(x => x.LanguageIsoCode == language.IsoCode).Value); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/DictionaryItemTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/DictionaryItemTests.cs index e0c6307f25..c6af23a475 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/DictionaryItemTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/DictionaryItemTests.cs @@ -1,11 +1,12 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Linq; using Newtonsoft.Json; using NUnit.Framework; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Extensions; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Models; @@ -58,4 +59,28 @@ public class DictionaryItemTests Assert.DoesNotThrow(() => JsonConvert.SerializeObject(item)); } + + [TestCase("en-AU", "en-AU value")] + [TestCase("en-GB", "en-GB value")] + [TestCase("en-US", "")] + public void Can_Get_Translated_Value_By_IsoCode(string isoCode, string expectedValue) + { + var item = _builder + .AddTranslation() + .AddLanguage() + .WithCultureInfo("en-AU") + .Done() + .WithValue("en-AU value") + .Done() + .AddTranslation() + .AddLanguage() + .WithCultureInfo("en-GB") + .Done() + .WithValue("en-GB value") + .Done() + .Build(); + + var value = item.GetTranslatedValue(isoCode); + Assert.AreEqual(expectedValue, value); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/DictionaryTranslationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/DictionaryTranslationTests.cs index ec81a4638f..34fc83a43a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/DictionaryTranslationTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/DictionaryTranslationTests.cs @@ -32,12 +32,7 @@ public class DictionaryTranslationTests Assert.AreEqual(clone.Id, item.Id); Assert.AreEqual(clone.Key, item.Key); Assert.AreEqual(clone.UpdateDate, item.UpdateDate); - Assert.AreNotSame(clone.Language, item.Language); - - // This is null because we are ignoring it from cloning due to caching/cloning issues - we don't really want - // this entity attached to this item but we're stuck with it for now - Assert.IsNull(clone.Language); - Assert.AreEqual(clone.LanguageId, item.LanguageId); + Assert.AreEqual(clone.LanguageIsoCode, item.LanguageIsoCode); Assert.AreEqual(clone.Value, item.Value); // This double verifies by reflection diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/Mappers/DictionaryTranslationMapperTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/Mappers/DictionaryTranslationMapperTest.cs index d36cf00c0f..c485e26453 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/Mappers/DictionaryTranslationMapperTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Persistence/Mappers/DictionaryTranslationMapperTest.cs @@ -21,17 +21,6 @@ public class DictionaryTranslationMapperTest Assert.That(column, Is.EqualTo("[cmsLanguageText].[UniqueId]")); } - [Test] - public void Can_Map_Language_Property() - { - // Act - var column = - new DictionaryTranslationMapper(TestHelper.GetMockSqlContext(), TestHelper.CreateMaps()).Map("Language"); - - // Assert - Assert.That(column, Is.EqualTo("[cmsLanguageText].[languageId]")); - } - [Test] public void Can_Map_Value_Property() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PublishedCache/PublishedContentLanguageVariantTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PublishedCache/PublishedContentLanguageVariantTests.cs index 342e08acbe..d5b219228c 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PublishedCache/PublishedContentLanguageVariantTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PublishedCache/PublishedContentLanguageVariantTests.cs @@ -62,13 +62,13 @@ public class PublishedContentLanguageVariantTests : PublishedSnapshotServiceTest { new("en-US", "English (United States)") { Id = 1, IsDefault = true }, new("fr", "French") { Id = 2 }, - new("es", "Spanish") { Id = 3, FallbackLanguageId = 1 }, - new("it", "Italian") { Id = 4, FallbackLanguageId = 3 }, + new("es", "Spanish") { Id = 3, FallbackIsoCode = "en-US" }, + new("it", "Italian") { Id = 4, FallbackIsoCode = "es" }, new("de", "German") { Id = 5 }, - new Language("da", "Danish") { Id = 6, FallbackLanguageId = 8 }, - new Language("sv", "Swedish") { Id = 7, FallbackLanguageId = 6 }, - new Language("no", "Norweigan") { Id = 8, FallbackLanguageId = 7 }, - new Language("nl", "Dutch") { Id = 9, FallbackLanguageId = 1 }, + new Language("da", "Danish") { Id = 6, FallbackIsoCode = "no" }, + new Language("sv", "Swedish") { Id = 7, FallbackIsoCode = "da" }, + new Language("no", "Norweigan") { Id = 8, FallbackIsoCode = "sv" }, + new Language("nl", "Dutch") { Id = 9, FallbackIsoCode = "en-US" }, }; localizationService.Setup(x => x.GetAllLanguages()).Returns(languages); @@ -136,6 +136,8 @@ public class PublishedContentLanguageVariantTests : PublishedSnapshotServiceTest .WithProperties(new PropertyDataBuilder() .WithPropertyData("welcomeText", "Welcome") .WithPropertyData("welcomeText", "Welcome", "en-US") + .WithPropertyData("numericField", 123) + .WithPropertyData("numericField", 123, "en-US") .WithPropertyData("noprop", "xxx") .Build()) @@ -314,9 +316,25 @@ public class PublishedContentLanguageVariantTests : PublishedSnapshotServiceTest var content = snapshot.Content.GetAtRoot().First().Children.First(); var value = content.Value(PublishedValueFallback, "welcomeText", "nl", fallback: Fallback.ToLanguage); + var numericValue = content.Value(PublishedValueFallback, "numericField", "nl", fallback: Fallback.ToLanguage); // No Dutch value is directly assigned. Check has fallen back to English value from language variant. Assert.AreEqual("Welcome", value); + Assert.AreEqual(123, numericValue); + } + + [Test] + public void Can_Get_Content_For_Property_With_Fallback_Language_Priority() + { + var snapshot = GetPublishedSnapshot(); + var content = snapshot.Content.GetAtRoot().First().Children.First(); + + var value = content.GetProperty("welcomeText")!.Value(PublishedValueFallback, "nl", fallback: Fallback.ToLanguage); + var numericValue = content.GetProperty("numericField")!.Value(PublishedValueFallback, "nl", fallback: Fallback.ToLanguage); + + // No Dutch value is directly assigned. Check has fallen back to English value from language variant. + Assert.AreEqual("Welcome", value); + Assert.AreEqual(123, numericValue); } [Test]