diff --git a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs index 3729ae1877..5313243958 100644 --- a/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Core/Migrations/Upgrade/UmbracoPlan.cs @@ -113,6 +113,7 @@ namespace Umbraco.Core.Migrations.Upgrade Chain("{1350617A-4930-4D61-852F-E3AA9E692173}"); Chain("{39E5B1F7-A50B-437E-B768-1723AEC45B65}"); // from 7.12.0 + Chain("{CF51B39B-9B9A-4740-BB7C-EAF606A7BFBF}"); //FINAL diff --git a/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/FallbackLanguage.cs b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/FallbackLanguage.cs new file mode 100644 index 0000000000..f0d7c02b82 --- /dev/null +++ b/src/Umbraco.Core/Migrations/Upgrade/V_8_0_0/FallbackLanguage.cs @@ -0,0 +1,24 @@ +using System.Linq; +using Umbraco.Core.Persistence.Dtos; + +namespace Umbraco.Core.Migrations.Upgrade.V_8_0_0 +{ + /// + /// Adds a new, self-joined field to umbracoLanguages to hold the fall-back language for + /// a given language. + /// + public class FallbackLanguage : MigrationBase + { + public FallbackLanguage(IMigrationContext context) + : base(context) + { } + + public override void Migrate() + { + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToArray(); + + if (columns.Any(x => x.TableName.InvariantEquals(Constants.DatabaseSchema.Tables.Language) && x.ColumnName.InvariantEquals("fallbackLanguageId")) == false) + AddColumn("fallbackLanguageId"); + } + } +} diff --git a/src/Umbraco.Core/Models/ILanguage.cs b/src/Umbraco.Core/Models/ILanguage.cs index 7bf9e9b32c..8d1c092e13 100644 --- a/src/Umbraco.Core/Models/ILanguage.cs +++ b/src/Umbraco.Core/Models/ILanguage.cs @@ -33,5 +33,11 @@ namespace Umbraco.Core.Models /// If true, a variant node cannot be published unless this language variant is created /// bool Mandatory { get; set; } + + /// + /// Defines the id of a fallback language that can be used in multi-lingual scenarios to provide + /// content if the requested language does not have it published. + /// + int? FallbackLanguageId { get; set; } } } diff --git a/src/Umbraco.Core/Models/Language.cs b/src/Umbraco.Core/Models/Language.cs index fa1c9dc826..5fcd5cd50e 100644 --- a/src/Umbraco.Core/Models/Language.cs +++ b/src/Umbraco.Core/Models/Language.cs @@ -19,6 +19,7 @@ namespace Umbraco.Core.Models private string _cultureName; private bool _isDefaultVariantLanguage; private bool _mandatory; + private int? _fallbackLanguageId; public Language(string isoCode) { @@ -32,6 +33,7 @@ namespace Umbraco.Core.Models public readonly PropertyInfo CultureNameSelector = ExpressionHelper.GetPropertyInfo(x => x.CultureName); public readonly PropertyInfo IsDefaultVariantLanguageSelector = ExpressionHelper.GetPropertyInfo(x => x.IsDefaultVariantLanguage); public readonly PropertyInfo MandatorySelector = ExpressionHelper.GetPropertyInfo(x => x.Mandatory); + public readonly PropertyInfo FallbackLanguageSelector = ExpressionHelper.GetPropertyInfo(x => x.FallbackLanguageId); } /// @@ -71,5 +73,11 @@ namespace Umbraco.Core.Models get => _mandatory; set => SetPropertyValueAndDetectChanges(value, ref _mandatory, Ps.Value.MandatorySelector); } + + public int? FallbackLanguageId + { + get => _fallbackLanguageId; + set => SetPropertyValueAndDetectChanges(value, ref _fallbackLanguageId, Ps.Value.FallbackLanguageSelector); + } } } diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedValueFallback.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedValueFallback.cs index 8e1dcfd543..f154d9ef27 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedValueFallback.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedValueFallback.cs @@ -2,6 +2,12 @@ namespace Umbraco.Core.Models.PublishedContent { + public enum PublishedValueFallbackPriority + { + RecursiveTree, + FallbackLanguage + } + /// /// Provides a fallback strategy for getting values. /// @@ -30,8 +36,8 @@ namespace Umbraco.Core.Models.PublishedContent T GetValue(IPublishedElement content, string alias, string culture, string segment, T defaultValue); - object GetValue(IPublishedContent content, string alias, string culture, string segment, object defaultValue, bool recurse); + object GetValue(IPublishedContent content, string alias, string culture, string segment, object defaultValue, bool recurse, PublishedValueFallbackPriority fallbackPriority); - T GetValue(IPublishedContent content, string alias, string culture, string segment, T defaultValue, bool recurse); + T GetValue(IPublishedContent content, string alias, string culture, string segment, T defaultValue, bool recurse, PublishedValueFallbackPriority fallbackPriority); } } diff --git a/src/Umbraco.Core/Models/PublishedContent/NoopPublishedValueFallback.cs b/src/Umbraco.Core/Models/PublishedContent/NoopPublishedValueFallback.cs index b99b4ad415..75ab9df35a 100644 --- a/src/Umbraco.Core/Models/PublishedContent/NoopPublishedValueFallback.cs +++ b/src/Umbraco.Core/Models/PublishedContent/NoopPublishedValueFallback.cs @@ -21,9 +21,9 @@ public T GetValue(IPublishedElement content, string alias, string culture, string segment, T defaultValue) => defaultValue; /// - public object GetValue(IPublishedContent content, string alias, string culture, string segment, object defaultValue, bool recurse) => defaultValue; + public object GetValue(IPublishedContent content, string alias, string culture, string segment, object defaultValue, bool recurse, PublishedValueFallbackPriority fallbackPriority) => defaultValue; /// - public T GetValue(IPublishedContent content, string alias, string culture, string segment, T defaultValue, bool recurse) => defaultValue; + public T GetValue(IPublishedContent content, string alias, string culture, string segment, T defaultValue, bool recurse, PublishedValueFallbackPriority fallbackPriority) => defaultValue; } -} \ No newline at end of file +} diff --git a/src/Umbraco.Core/Persistence/Dtos/LanguageDto.cs b/src/Umbraco.Core/Persistence/Dtos/LanguageDto.cs index 12c9fd0bd4..f87930269a 100644 --- a/src/Umbraco.Core/Persistence/Dtos/LanguageDto.cs +++ b/src/Umbraco.Core/Persistence/Dtos/LanguageDto.cs @@ -38,5 +38,14 @@ namespace Umbraco.Core.Persistence.Dtos [Column("mandatory")] [Constraint(Default = "0")] public bool Mandatory { get; set; } + + /// + /// Defines the fallback language that can be used in multi-lingual scenarios to provide + /// content if the requested language does not have it published. + /// + [Column("fallbackLanguageId")] + [ForeignKey(typeof(LanguageDto), Column = "id")] + [NullSetting(NullSetting = NullSettings.Null)] + public int? FallbackLanguageId { get; set; } } } diff --git a/src/Umbraco.Core/Persistence/Factories/LanguageFactory.cs b/src/Umbraco.Core/Persistence/Factories/LanguageFactory.cs index 7b24411498..7ab36d15d6 100644 --- a/src/Umbraco.Core/Persistence/Factories/LanguageFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/LanguageFactory.cs @@ -8,7 +8,7 @@ namespace Umbraco.Core.Persistence.Factories { public static ILanguage BuildEntity(LanguageDto dto) { - var lang = new Language(dto.IsoCode) { CultureName = dto.CultureName, Id = dto.Id, IsDefaultVariantLanguage = dto.IsDefaultVariantLanguage, Mandatory = dto.Mandatory }; + var lang = new Language(dto.IsoCode) { CultureName = dto.CultureName, Id = dto.Id, IsDefaultVariantLanguage = dto.IsDefaultVariantLanguage, Mandatory = dto.Mandatory, FallbackLanguageId = dto.FallbackLanguageId }; // reset dirty initial properties (U4-1946) lang.ResetDirtyProperties(false); return lang; @@ -16,9 +16,11 @@ namespace Umbraco.Core.Persistence.Factories public static LanguageDto BuildDto(ILanguage entity) { - var dto = new LanguageDto { CultureName = entity.CultureName, IsoCode = entity.IsoCode, IsDefaultVariantLanguage = entity.IsDefaultVariantLanguage, Mandatory = entity.Mandatory }; + var dto = new LanguageDto { CultureName = entity.CultureName, IsoCode = entity.IsoCode, IsDefaultVariantLanguage = entity.IsDefaultVariantLanguage, Mandatory = entity.Mandatory, FallbackLanguageId = entity.FallbackLanguageId }; if (entity.HasIdentity) + { dto.Id = short.Parse(entity.Id.ToString(CultureInfo.InvariantCulture)); + } return dto; } diff --git a/src/Umbraco.Core/Persistence/Repositories/Implement/LanguageRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Implement/LanguageRepository.cs index 740015683a..787cbc1690 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Implement/LanguageRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Implement/LanguageRepository.cs @@ -52,7 +52,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement sql.OrderBy(dto => dto.Id); // get languages - var languages = Database.Fetch(sql).Select(ConvertFromDto).OrderBy(x => x.Id).ToList(); + var dtos = Database.Fetch(sql); + var languages = dtos.Select(ConvertFromDto).ToList(); // fix inconsistencies: there has to be a default language, and it has to be mandatory var defaultLanguage = languages.FirstOrDefault(x => x.IsDefaultVariantLanguage) ?? languages.First(); @@ -79,7 +80,8 @@ namespace Umbraco.Core.Persistence.Repositories.Implement var sqlClause = GetBaseQuery(false); var translator = new SqlTranslator(sqlClause, query); var sql = translator.Translate(); - return Database.Fetch(sql).Select(ConvertFromDto); + var dtos = Database.Fetch(sql); + return dtos.Select(ConvertFromDto).ToList(); } #endregion @@ -144,14 +146,12 @@ namespace Umbraco.Core.Persistence.Repositories.Implement IsolatedCache.ClearAllCache(); } -; var dto = LanguageFactory.BuildDto(entity); var id = Convert.ToInt32(Database.Insert(dto)); entity.Id = id; entity.ResetDirtyProperties(); - } protected override void PersistUpdatedItem(ILanguage entity) @@ -204,7 +204,7 @@ namespace Umbraco.Core.Persistence.Repositories.Implement var entity = LanguageFactory.BuildEntity(dto); return entity; } - + public ILanguage GetByIsoCode(string isoCode) { TypedCachePolicy.GetAllCached(PerformGetAll); // ensure cache is populated, in a non-expensive way diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 1689be9b85..5af5c6c5ab 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -335,6 +335,7 @@ + diff --git a/src/Umbraco.Tests/Persistence/Repositories/LanguageRepositoryTest.cs b/src/Umbraco.Tests/Persistence/Repositories/LanguageRepositoryTest.cs index cd1fe47f39..a63bf5e08d 100644 --- a/src/Umbraco.Tests/Persistence/Repositories/LanguageRepositoryTest.cs +++ b/src/Umbraco.Tests/Persistence/Repositories/LanguageRepositoryTest.cs @@ -47,6 +47,7 @@ namespace Umbraco.Tests.Persistence.Repositories Assert.That(language.HasIdentity, Is.True); Assert.That(language.CultureName, Is.EqualTo("en-US")); Assert.That(language.IsoCode, Is.EqualTo("en-US")); + Assert.That(language.FallbackLanguageId, Is.Null); } } @@ -61,7 +62,8 @@ namespace Umbraco.Tests.Persistence.Repositories var au = CultureInfo.GetCultureInfo("en-AU"); var language = (ILanguage)new Language(au.Name) { - CultureName = au.DisplayName + CultureName = au.DisplayName, + FallbackLanguageId = 1 }; repository.Save(language); @@ -73,6 +75,7 @@ namespace Umbraco.Tests.Persistence.Repositories Assert.That(language.HasIdentity, Is.True); Assert.That(language.CultureName, Is.EqualTo(au.DisplayName)); Assert.That(language.IsoCode, Is.EqualTo(au.Name)); + Assert.That(language.FallbackLanguageId, Is.EqualTo(1)); } } @@ -182,7 +185,7 @@ namespace Umbraco.Tests.Persistence.Repositories var repository = CreateRepository(provider); // Act - var languageBR = new Language("pt-BR") {CultureName = "pt-BR"}; + var languageBR = new Language("pt-BR") { CultureName = "pt-BR" }; repository.Save(languageBR); // Assert @@ -190,6 +193,7 @@ namespace Umbraco.Tests.Persistence.Repositories Assert.That(languageBR.Id, Is.EqualTo(6)); //With 5 existing entries the Id should be 6 Assert.IsFalse(languageBR.IsDefaultVariantLanguage); Assert.IsFalse(languageBR.Mandatory); + Assert.IsNull(languageBR.FallbackLanguageId); } } @@ -211,6 +215,31 @@ namespace Umbraco.Tests.Persistence.Repositories Assert.That(languageBR.Id, Is.EqualTo(6)); //With 5 existing entries the Id should be 6 Assert.IsTrue(languageBR.IsDefaultVariantLanguage); Assert.IsTrue(languageBR.Mandatory); + Assert.IsNull(languageBR.FallbackLanguageId); + } + } + + [Test] + public void Can_Perform_Add_On_LanguageRepository_With_Fallback_Language() + { + // Arrange + var provider = TestObjects.GetScopeProvider(Logger); + using (var scope = provider.CreateScope()) + { + var repository = CreateRepository(provider); + + // Act + var languageBR = new Language("pt-BR") + { + CultureName = "pt-BR", + FallbackLanguageId = 1 + }; + 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)); } } @@ -232,13 +261,11 @@ namespace Umbraco.Tests.Persistence.Repositories Assert.IsTrue(languageBR.Mandatory); // Act - var languageNZ = new Language("en-NZ") { CultureName = "en-NZ", IsDefaultVariantLanguage = true, Mandatory = true }; repository.Save(languageNZ); languageBR = repository.Get(languageBR.Id); // Assert - Assert.IsFalse(languageBR.IsDefaultVariantLanguage); Assert.IsTrue(languageNZ.IsDefaultVariantLanguage); } @@ -257,6 +284,7 @@ namespace Umbraco.Tests.Persistence.Repositories var language = repository.Get(5); language.IsoCode = "pt-BR"; language.CultureName = "pt-BR"; + language.FallbackLanguageId = 1; repository.Save(language); @@ -266,6 +294,7 @@ namespace Umbraco.Tests.Persistence.Repositories Assert.That(languageUpdated, Is.Not.Null); Assert.That(languageUpdated.IsoCode, Is.EqualTo("pt-BR")); Assert.That(languageUpdated.CultureName, Is.EqualTo("pt-BR")); + Assert.That(languageUpdated.FallbackLanguageId, Is.EqualTo(1)); } } @@ -314,7 +343,7 @@ namespace Umbraco.Tests.Persistence.Repositories base.TearDown(); } - public void CreateTestData() + private void CreateTestData() { var languageDK = new Language("da-DK") { CultureName = "da-DK" }; ServiceContext.LocalizationService.Save(languageDK);//Id 2 diff --git a/src/Umbraco.Tests/PublishedContent/PublishedContentLanuageVariantTests.cs b/src/Umbraco.Tests/PublishedContent/PublishedContentLanuageVariantTests.cs new file mode 100644 index 0000000000..22eb4bd799 --- /dev/null +++ b/src/Umbraco.Tests/PublishedContent/PublishedContentLanuageVariantTests.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Moq; +using NUnit.Framework; +using Umbraco.Core.Composing; +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.Services; +using Umbraco.Tests.Testing; +using Umbraco.Web; + +namespace Umbraco.Tests.PublishedContent +{ + [TestFixture] + [UmbracoTest(PluginManager = UmbracoTestOptions.PluginManager.PerFixture)] + public class PublishedContentLanuageVariantTests : PublishedContentSnapshotTestBase + { + protected override void Compose() + { + base.Compose(); + + Container.RegisterSingleton(_ => GetServiceContext()); + } + + protected ServiceContext GetServiceContext() + { + var serviceContext = TestObjects.GetServiceContextMock(Container); + MockLocalizationService(serviceContext); + return serviceContext; + } + + private static void MockLocalizationService(ServiceContext serviceContext) + { + // Set up languages. + // Spanish falls back to English and Italian to Spanish (and then to English). + // French has no fall back. + var languages = new List + { + new Language("en-US") { Id = 1, CultureName = "English", IsDefaultVariantLanguage = true }, + new Language("fr") { Id = 2, CultureName = "French" }, + new Language("es") { Id = 3, CultureName = "Spanish", FallbackLanguageId = 1 }, + new Language("it") { Id = 4, CultureName = "Italian", FallbackLanguageId = 3 }, + new Language("de") { Id = 5, CultureName = "German" } + }; + + var localizationService = Mock.Get(serviceContext.LocalizationService); + localizationService.Setup(x => x.GetAllLanguages()).Returns(languages); + localizationService.Setup(x => x.GetLanguageById(It.IsAny())) + .Returns((int id) => languages.SingleOrDefault(y => y.Id == id)); + localizationService.Setup(x => x.GetLanguageByIsoCode(It.IsAny())) + .Returns((string c) => languages.SingleOrDefault(y => y.IsoCode == c)); + } + + internal override void PopulateCache(PublishedContentTypeFactory factory, SolidPublishedContentCache cache) + { + var props = new[] + { + factory.CreatePropertyType("prop1", 1), + }; + var contentType1 = factory.CreateContentType(1, "ContentType1", Enumerable.Empty(), props); + + var prop1 = new SolidPublishedPropertyWithLanguageVariants + { + Alias = "welcomeText", + }; + prop1.SetSourceValue("en-US", "Welcome"); + prop1.SetValue("en-US", "Welcome"); + prop1.SetSourceValue("de", "Willkommen"); + prop1.SetValue("de", "Willkommen"); + + cache.Add(new SolidPublishedContent(contentType1) + { + Id = 1, + SortOrder = 0, + Name = "Content 1", + UrlSegment = "content-1", + Path = "/1", + Level = 1, + Url = "/content-1", + ParentId = -1, + ChildIds = new int[] { }, + Properties = new Collection + { + prop1 + } + }); + } + + [Test] + public void Can_Get_Content_For_Populated_Requested_Language() + { + var content = UmbracoContext.Current.ContentCache.GetAtRoot().First(); + var value = content.Value("welcomeText", "en-US"); + Assert.AreEqual("Welcome", value); + } + + [Test] + public void Can_Get_Content_For_Populated_Requested_Non_Default_Language() + { + var content = UmbracoContext.Current.ContentCache.GetAtRoot().First(); + var value = content.Value("welcomeText", "de"); + Assert.AreEqual("Willkommen", value); + } + + [Test] + public void Do_Not_Get_Content_For_Unpopulated_Requested_Language_Without_Fallback() + { + var content = UmbracoContext.Current.ContentCache.GetAtRoot().First(); + var value = content.Value("welcomeText", "fr"); + Assert.IsNull(value); + } + + [Test] + public void Can_Get_Content_For_Unpopulated_Requested_Language_With_Fallback() + { + var content = UmbracoContext.Current.ContentCache.GetAtRoot().First(); + var value = content.Value("welcomeText", "es"); + Assert.AreEqual("Welcome", value); + } + + [Test] + public void Can_Get_Content_For_Unpopulated_Requested_Language_With_Fallback_Over_Two_Levels() + { + var content = UmbracoContext.Current.ContentCache.GetAtRoot().First(); + var value = content.Value("welcomeText", "it"); + Assert.AreEqual("Welcome", value); + } + } +} diff --git a/src/Umbraco.Tests/PublishedContent/PublishedContentMoreTests.cs b/src/Umbraco.Tests/PublishedContent/PublishedContentMoreTests.cs index 101c7827c3..22965ac141 100644 --- a/src/Umbraco.Tests/PublishedContent/PublishedContentMoreTests.cs +++ b/src/Umbraco.Tests/PublishedContent/PublishedContentMoreTests.cs @@ -1,91 +1,94 @@ -using System; +using System.Collections.ObjectModel; using System.Linq; -using System.Collections.ObjectModel; -using System.Web.Routing; -using Moq; using NUnit.Framework; using Umbraco.Core.Models.PublishedContent; using Umbraco.Web; -using Umbraco.Web.PublishedCache; -using Umbraco.Web.Routing; -using Umbraco.Web.Security; -using Umbraco.Core.Composing; -using Current = Umbraco.Core.Composing.Current; -using LightInject; -using Umbraco.Core.Logging; -using Umbraco.Core.Models; -using Umbraco.Core.PropertyEditors; -using Umbraco.Core.Services; -using Umbraco.Tests.TestHelpers; using Umbraco.Tests.Testing; -using Umbraco.Tests.Testing.Objects.Accessors; namespace Umbraco.Tests.PublishedContent { [TestFixture] [UmbracoTest(PluginManager = UmbracoTestOptions.PluginManager.PerFixture)] - public class PublishedContentMoreTests : PublishedContentTestBase + public class PublishedContentMoreTests : PublishedContentSnapshotTestBase { - // read http://stackoverflow.com/questions/7713326/extension-method-that-works-on-ienumerablet-and-iqueryablet - // and http://msmvps.com/blogs/jon_skeet/archive/2010/10/28/overloading-and-generic-constraints.aspx - // and http://blogs.msdn.com/b/ericlippert/archive/2009/12/10/constraints-are-not-part-of-the-signature.aspx - - public override void SetUp() + internal override void PopulateCache(PublishedContentTypeFactory factory, SolidPublishedContentCache cache) { - base.SetUp(); + var props = new[] + { + factory.CreatePropertyType("prop1", 1), + }; + var contentType1 = factory.CreateContentType(1, "ContentType1", Enumerable.Empty(), props); + var contentType2 = factory.CreateContentType(2, "ContentType2", Enumerable.Empty(), props); + var contentType2Sub = factory.CreateContentType(3, "ContentType2Sub", Enumerable.Empty(), props); - var umbracoContext = GetUmbracoContext(); - Umbraco.Web.Composing.Current.UmbracoContextAccessor.UmbracoContext = umbracoContext; - } + cache.Add(new SolidPublishedContent(contentType1) + { + Id = 1, + SortOrder = 0, + Name = "Content 1", + UrlSegment = "content-1", + Path = "/1", + Level = 1, + Url = "/content-1", + ParentId = -1, + ChildIds = new int[] { }, + Properties = new Collection + { + new SolidPublishedProperty + { + Alias = "prop1", + SolidHasValue = true, + SolidValue = 1234, + SolidSourceValue = "1234" + } + } + }); - protected override void Compose() - { - base.Compose(); + cache.Add(new SolidPublishedContent(contentType2) + { + Id = 2, + SortOrder = 1, + Name = "Content 2", + UrlSegment = "content-2", + Path = "/2", + Level = 1, + Url = "/content-2", + ParentId = -1, + ChildIds = new int[] { }, + Properties = new Collection + { + new SolidPublishedProperty + { + Alias = "prop1", + SolidHasValue = true, + SolidValue = 1234, + SolidSourceValue = "1234" + } + } + }); - Container.RegisterSingleton(f => new PublishedModelFactory(f.GetInstance().GetTypes())); - } - - protected override TypeLoader CreatePluginManager(IServiceFactory f) - { - var pluginManager = base.CreatePluginManager(f); - - // this is so the model factory looks into the test assembly - pluginManager.AssembliesToScan = pluginManager.AssembliesToScan - .Union(new[] { typeof (PublishedContentMoreTests).Assembly }) - .ToList(); - - return pluginManager; - } - - private UmbracoContext GetUmbracoContext() - { - RouteData routeData = null; - - var publishedSnapshot = CreatePublishedSnapshot(); - - var publishedSnapshotService = new Mock(); - publishedSnapshotService.Setup(x => x.CreatePublishedSnapshot(It.IsAny())).Returns(publishedSnapshot); - - var globalSettings = TestObjects.GetGlobalSettings(); - - var httpContext = GetHttpContextFactory("http://umbraco.local/", routeData).HttpContext; - var umbracoContext = new UmbracoContext( - httpContext, - publishedSnapshotService.Object, - new WebSecurity(httpContext, Current.Services.UserService, globalSettings), - TestObjects.GetUmbracoSettings(), - Enumerable.Empty(), - globalSettings, - new TestVariationContextAccessor()); - - return umbracoContext; - } - - public override void TearDown() - { - base.TearDown(); - - Current.Reset(); + cache.Add(new SolidPublishedContent(contentType2Sub) + { + Id = 3, + SortOrder = 2, + Name = "Content 2Sub", + UrlSegment = "content-2sub", + Path = "/3", + Level = 1, + Url = "/content-2sub", + ParentId = -1, + ChildIds = new int[] { }, + Properties = new Collection + { + new SolidPublishedProperty + { + Alias = "prop1", + SolidHasValue = true, + SolidValue = 1234, + SolidSourceValue = "1234" + } + } + }); } [Test] @@ -197,95 +200,5 @@ namespace Umbraco.Tests.PublishedContent Assert.AreEqual(1, result[0].Id); Assert.AreEqual(2, result[1].Id); } - - private static SolidPublishedSnapshot CreatePublishedSnapshot() - { - var dataTypeService = new TestObjects.TestDataTypeService( - new DataType(new VoidEditor(Mock.Of())) { Id = 1 }); - - var factory = new PublishedContentTypeFactory(Mock.Of(), new PropertyValueConverterCollection(Array.Empty()), dataTypeService); - var caches = new SolidPublishedSnapshot(); - var cache = caches.InnerContentCache; - - var props = new[] - { - factory.CreatePropertyType("prop1", 1), - }; - - var contentType1 = factory.CreateContentType(1, "ContentType1", Enumerable.Empty(), props); - var contentType2 = factory.CreateContentType(2, "ContentType2", Enumerable.Empty(), props); - var contentType2Sub = factory.CreateContentType(3, "ContentType2Sub", Enumerable.Empty(), props); - - cache.Add(new SolidPublishedContent(contentType1) - { - Id = 1, - SortOrder = 0, - Name = "Content 1", - UrlSegment = "content-1", - Path = "/1", - Level = 1, - Url = "/content-1", - ParentId = -1, - ChildIds = new int[] {}, - Properties = new Collection - { - new SolidPublishedProperty - { - Alias = "prop1", - SolidHasValue = true, - SolidValue = 1234, - SolidSourceValue = "1234" - } - } - }); - - cache.Add(new SolidPublishedContent(contentType2) - { - Id = 2, - SortOrder = 1, - Name = "Content 2", - UrlSegment = "content-2", - Path = "/2", - Level = 1, - Url = "/content-2", - ParentId = -1, - ChildIds = new int[] { }, - Properties = new Collection - { - new SolidPublishedProperty - { - Alias = "prop1", - SolidHasValue = true, - SolidValue = 1234, - SolidSourceValue = "1234" - } - } - }); - - cache.Add(new SolidPublishedContent(contentType2Sub) - { - Id = 3, - SortOrder = 2, - Name = "Content 2Sub", - UrlSegment = "content-2sub", - Path = "/3", - Level = 1, - Url = "/content-2sub", - ParentId = -1, - ChildIds = new int[] { }, - Properties = new Collection - { - new SolidPublishedProperty - { - Alias = "prop1", - SolidHasValue = true, - SolidValue = 1234, - SolidSourceValue = "1234" - } - } - }); - - return caches; - } } } diff --git a/src/Umbraco.Tests/PublishedContent/PublishedContentSnapshotTestBase.cs b/src/Umbraco.Tests/PublishedContent/PublishedContentSnapshotTestBase.cs new file mode 100644 index 0000000000..623472a023 --- /dev/null +++ b/src/Umbraco.Tests/PublishedContent/PublishedContentSnapshotTestBase.cs @@ -0,0 +1,100 @@ +using System; +using System.Linq; +using System.Collections.ObjectModel; +using System.Web.Routing; +using Moq; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Web; +using Umbraco.Web.PublishedCache; +using Umbraco.Web.Routing; +using Umbraco.Web.Security; +using Umbraco.Core.Composing; +using Current = Umbraco.Core.Composing.Current; +using LightInject; +using Umbraco.Core.Logging; +using Umbraco.Core.Models; +using Umbraco.Core.PropertyEditors; +using Umbraco.Tests.TestHelpers; +using Umbraco.Tests.Testing.Objects.Accessors; + +namespace Umbraco.Tests.PublishedContent +{ + public abstract class PublishedContentSnapshotTestBase : PublishedContentTestBase + { + // read http://stackoverflow.com/questions/7713326/extension-method-that-works-on-ienumerablet-and-iqueryablet + // and http://msmvps.com/blogs/jon_skeet/archive/2010/10/28/overloading-and-generic-constraints.aspx + // and http://blogs.msdn.com/b/ericlippert/archive/2009/12/10/constraints-are-not-part-of-the-signature.aspx + + public override void SetUp() + { + base.SetUp(); + + var umbracoContext = GetUmbracoContext(); + Umbraco.Web.Composing.Current.UmbracoContextAccessor.UmbracoContext = umbracoContext; + } + + protected override void Compose() + { + base.Compose(); + + Container.RegisterSingleton(f => new PublishedModelFactory(f.GetInstance().GetTypes())); + } + + protected override TypeLoader CreatePluginManager(IServiceFactory f) + { + var pluginManager = base.CreatePluginManager(f); + + // this is so the model factory looks into the test assembly + pluginManager.AssembliesToScan = pluginManager.AssembliesToScan + .Union(new[] { typeof (PublishedContentMoreTests).Assembly }) + .ToList(); + + return pluginManager; + } + + private UmbracoContext GetUmbracoContext() + { + RouteData routeData = null; + + var publishedSnapshot = CreatePublishedSnapshot(); + + var publishedSnapshotService = new Mock(); + publishedSnapshotService.Setup(x => x.CreatePublishedSnapshot(It.IsAny())).Returns(publishedSnapshot); + + var globalSettings = TestObjects.GetGlobalSettings(); + + var httpContext = GetHttpContextFactory("http://umbraco.local/", routeData).HttpContext; + var umbracoContext = new UmbracoContext( + httpContext, + publishedSnapshotService.Object, + new WebSecurity(httpContext, Current.Services.UserService, globalSettings), + TestObjects.GetUmbracoSettings(), + Enumerable.Empty(), + globalSettings, + new TestVariationContextAccessor()); + + return umbracoContext; + } + + public override void TearDown() + { + base.TearDown(); + + Current.Reset(); + } + + private SolidPublishedSnapshot CreatePublishedSnapshot() + { + var dataTypeService = new TestObjects.TestDataTypeService( + new DataType(new VoidEditor(Mock.Of())) { Id = 1 }); + + var factory = new PublishedContentTypeFactory(Mock.Of(), new PropertyValueConverterCollection(Array.Empty()), dataTypeService); + var caches = new SolidPublishedSnapshot(); + var cache = caches.InnerContentCache; + PopulateCache(factory, cache); + return caches; + } + + internal abstract void PopulateCache(PublishedContentTypeFactory factory, SolidPublishedContentCache cache); + } +} diff --git a/src/Umbraco.Tests/PublishedContent/PublishedContentTests.cs b/src/Umbraco.Tests/PublishedContent/PublishedContentTests.cs index fa120065bf..464f2a4cc1 100644 --- a/src/Umbraco.Tests/PublishedContent/PublishedContentTests.cs +++ b/src/Umbraco.Tests/PublishedContent/PublishedContentTests.cs @@ -34,11 +34,11 @@ namespace Umbraco.Tests.PublishedContent Container.RegisterSingleton(f => new PublishedModelFactory(f.GetInstance().GetTypes())); Container.RegisterSingleton(); - Container.RegisterSingleton(); + Container.RegisterSingleton(); var logger = Mock.Of(); var dataTypeService = new TestObjects.TestDataTypeService( - new DataType(new VoidEditor(logger)) { Id = 1}, + new DataType(new VoidEditor(logger)) { Id = 1 }, new DataType(new TrueFalsePropertyEditor(logger)) { Id = 1001 }, new DataType(new RichTextPropertyEditor(logger)) { Id = 1002 }, new DataType(new IntegerPropertyEditor(logger)) { Id = 1003 }, @@ -333,7 +333,7 @@ namespace Umbraco.Tests.PublishedContent } [Test] - public void GetPropertyValueRecursiveTest() + public void Get_Property_Value_Recursive() { var doc = GetNode(1174); var rVal = doc.Value("testRecursive", recurse: true); diff --git a/src/Umbraco.Tests/PublishedContent/SolidPublishedSnapshot.cs b/src/Umbraco.Tests/PublishedContent/SolidPublishedSnapshot.cs index 756b775e46..af35e8c972 100644 --- a/src/Umbraco.Tests/PublishedContent/SolidPublishedSnapshot.cs +++ b/src/Umbraco.Tests/PublishedContent/SolidPublishedSnapshot.cs @@ -257,10 +257,72 @@ namespace Umbraco.Tests.PublishedContent public bool SolidHasValue { get; set; } public object SolidXPathValue { get; set; } - public object GetSourceValue(string culture = null, string segment = null) => SolidSourceValue; - public object GetValue(string culture = null, string segment = null) => SolidValue; - public object GetXPathValue(string culture = null, string segment = null) => SolidXPathValue; - public bool HasValue(string culture = null, string segment = null) => SolidHasValue; + public virtual object GetSourceValue(string culture = null, string segment = null) => SolidSourceValue; + public virtual object GetValue(string culture = null, string segment = null) => SolidValue; + public virtual object GetXPathValue(string culture = null, string segment = null) => SolidXPathValue; + public virtual bool HasValue(string culture = null, string segment = null) => SolidHasValue; + } + + internal class SolidPublishedPropertyWithLanguageVariants : SolidPublishedProperty + { + private readonly IDictionary _solidSourceValues = new Dictionary(); + private readonly IDictionary _solidValues = new Dictionary(); + private readonly IDictionary _solidXPathValues = new Dictionary(); + + public override object GetSourceValue(string culture = null, string segment = null) + { + if (string.IsNullOrEmpty(culture)) + { + return base.GetSourceValue(culture, segment); + } + + return _solidSourceValues.ContainsKey(culture) ? _solidSourceValues[culture] : null; + } + + public override object GetValue(string culture = null, string segment = null) + { + if (string.IsNullOrEmpty(culture)) + { + return base.GetValue(culture, segment); + } + + return _solidValues.ContainsKey(culture) ? _solidValues[culture] : null; + } + + public override object GetXPathValue(string culture = null, string segment = null) + { + if (string.IsNullOrEmpty(culture)) + { + return base.GetXPathValue(culture, segment); + } + + return _solidXPathValues.ContainsKey(culture) ? _solidXPathValues[culture] : null; + } + + public override bool HasValue(string culture = null, string segment = null) + { + if (string.IsNullOrEmpty(culture)) + { + return base.HasValue(culture, segment); + } + + return _solidSourceValues.ContainsKey(culture); + } + + public void SetSourceValue(string culture, object value) + { + _solidSourceValues.Add(culture, value); + } + + public void SetValue(string culture, object value) + { + _solidValues.Add(culture, value); + } + + public void SetXPathValue(string culture, object value) + { + _solidXPathValues.Add(culture, value); + } } [PublishedModel("ContentType2")] diff --git a/src/Umbraco.Tests/TestHelpers/BaseWebTest.cs b/src/Umbraco.Tests/TestHelpers/BaseWebTest.cs index 5eea6bcf72..2f7fe8700b 100644 --- a/src/Umbraco.Tests/TestHelpers/BaseWebTest.cs +++ b/src/Umbraco.Tests/TestHelpers/BaseWebTest.cs @@ -28,7 +28,7 @@ namespace Umbraco.Tests.TestHelpers { base.Compose(); - Container.RegisterSingleton(); + Container.RegisterSingleton(); Container.RegisterSingleton(); } diff --git a/src/Umbraco.Tests/Umbraco.Tests.csproj b/src/Umbraco.Tests/Umbraco.Tests.csproj index fc70e6ae9e..0b984b1167 100644 --- a/src/Umbraco.Tests/Umbraco.Tests.csproj +++ b/src/Umbraco.Tests/Umbraco.Tests.csproj @@ -122,6 +122,8 @@ + + diff --git a/src/Umbraco.Web.UI.Client/src/views/languages/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/languages/edit.controller.js index afb5333ded..523ef867cf 100644 --- a/src/Umbraco.Web.UI.Client/src/views/languages/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/languages/edit.controller.js @@ -29,7 +29,10 @@ "languages_mandatoryLanguageHelp", "languages_defaultLanguage", "languages_defaultLanguageHelp", - "languages_addLanguage" + "languages_addLanguage", + "languages_noFallbackLanguageOption", + "languages_fallbackLanguageDescription", + "languages_fallbackLanguage" ]; localizationService.localizeMany(labelKeys).then(function (values) { @@ -39,8 +42,17 @@ vm.labels.defaultLanguage = values[3]; vm.labels.defaultLanguageHelp = values[4]; vm.labels.addLanguage = values[5]; + vm.labels.noFallbackLanguageOption = values[6]; - if($routeParams.create) { + $scope.properties = { + fallbackLanguage: { + alias: "fallbackLanguage", + description: values[7], + label: values[8] + } + }; + + if ($routeParams.create) { vm.page.name = vm.labels.addLanguage; languageResource.getCultures().then(function (culturesDictionary) { var cultures = []; @@ -53,10 +65,17 @@ vm.availableCultures = cultures; }); } - }); - if(!$routeParams.create) { + vm.loading = true; + languageResource.getAll().then(function (languages) { + vm.availableLanguages = languages.filter(function (l) { + return $routeParams.id != l.id; + }); + vm.loading = false; + }); + + if (!$routeParams.create) { vm.loading = true; diff --git a/src/Umbraco.Web.UI.Client/src/views/languages/edit.html b/src/Umbraco.Web.UI.Client/src/views/languages/edit.html index 6aaf915960..a2217a6649 100644 --- a/src/Umbraco.Web.UI.Client/src/views/languages/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/languages/edit.html @@ -14,11 +14,11 @@ hide-alias="true"> - + - + @@ -64,6 +64,16 @@ + +
+ +
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/languages/overview.controller.js b/src/Umbraco.Web.UI.Client/src/views/languages/overview.controller.js index c81f93c7d6..a5c446dfb5 100644 --- a/src/Umbraco.Web.UI.Client/src/views/languages/overview.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/languages/overview.controller.js @@ -13,6 +13,16 @@ vm.editLanguage = editLanguage; vm.deleteLanguage = deleteLanguage; + vm.getLanguageById = function(id) { + for (var i = 0; i < vm.languages.length; i++) { + if (vm.languages[i].id === id) { + return vm.languages[i]; + } + } + + return null; + }; + function init() { vm.loading = true; @@ -22,17 +32,19 @@ "treeHeaders_languages", "general_mandatory", "general_default", + "languages_fallsbackToLabel" ]; localizationService.localizeMany(labelKeys).then(function (values) { vm.labels.languages = values[0]; vm.labels.mandatory = values[1]; vm.labels.general = values[2]; + vm.labels.fallsbackTo = values[3]; // set page name vm.page.name = vm.labels.languages; }); - languageResource.getAll().then(function(languages) { + languageResource.getAll().then(function (languages) { vm.languages = languages; vm.loading = false; }); diff --git a/src/Umbraco.Web.UI.Client/src/views/languages/overview.html b/src/Umbraco.Web.UI.Client/src/views/languages/overview.html index 90764d3f67..3b75fa62bd 100644 --- a/src/Umbraco.Web.UI.Client/src/views/languages/overview.html +++ b/src/Umbraco.Web.UI.Client/src/views/languages/overview.html @@ -36,6 +36,7 @@ - {{vm.labels.general}} {{vm.labels.mandatory}} + {{vm.labels.fallsbackTo}}: {{vm.getLanguageById(language.fallbackLanguageId).name}} Add language Mandatory - Properties on this language has to be filled out before the node can be published. + Properties on this language have to be filled out before the node can be published. Default language An Umbraco site can only have one default langugae set. Switching default language may result in default content missing. + Falls back to + No fall back language + To allow multi-lingual content to fall back to another language if not present in the requested language, select it here. + Fall back language diff --git a/src/Umbraco.Web/Editors/LanguageController.cs b/src/Umbraco.Web/Editors/LanguageController.cs index 96019da702..0de3aca634 100644 --- a/src/Umbraco.Web/Editors/LanguageController.cs +++ b/src/Umbraco.Web/Editors/LanguageController.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; @@ -8,8 +7,6 @@ using System.Web.Http; using AutoMapper; using Umbraco.Core; using Umbraco.Core.Models; -using Umbraco.Core.Persistence; -using Umbraco.Web.Models; using Umbraco.Web.Mvc; using Umbraco.Web.WebApi; using Umbraco.Web.WebApi.Filters; @@ -69,12 +66,11 @@ namespace Umbraco.Web.Editors } else if (allLangs.All(x => !x.IsDefaultVariantLanguage)) { - //if no language has the default flag, then the defaul language is the one with the lowest id + //if no language has the default flag, then the default language is the one with the lowest id model.IsDefaultVariantLanguage = allLangs[0].Id == lang.Id; model.Mandatory = allLangs[0].Id == lang.Id; } } - return model; } @@ -88,13 +84,23 @@ namespace Umbraco.Web.Editors public IHttpActionResult DeleteLanguage(int id) { var language = Services.LocalizationService.GetLanguageById(id); - if (language == null) return NotFound(); + if (language == null) + { + return NotFound(); + } - var totalLangs = Services.LocalizationService.GetAllLanguages().Count(); + var langs = Services.LocalizationService.GetAllLanguages().ToArray(); + var totalLangs = langs.Length; if (language.IsDefaultVariantLanguage || totalLangs == 1) { - var message = $"Language '{language.IsoCode}' is currently set to 'default' or it is the only installed language and can not be deleted."; + var message = $"Language '{language.CultureName}' is currently set to 'default' or it is the only installed language and cannot be deleted."; + throw new HttpResponseException(Request.CreateNotificationValidationErrorResponse(message)); + } + + if (langs.Any(x => x.FallbackLanguageId.HasValue && x.FallbackLanguageId.Value == language.Id)) + { + var message = $"Language '{language.CultureName}' is defined as a fall-back language for one or more other languages, and so cannot be deleted."; throw new HttpResponseException(Request.CreateNotificationValidationErrorResponse(message)); } @@ -136,21 +142,62 @@ namespace Umbraco.Web.Editors } //create it - var newLang = new Umbraco.Core.Models.Language(culture.Name) + var newLang = new Core.Models.Language(culture.Name) { CultureName = culture.DisplayName, IsDefaultVariantLanguage = language.IsDefaultVariantLanguage, - Mandatory = language.Mandatory + Mandatory = language.Mandatory, + FallbackLanguageId = language.FallbackLanguageId }; + Services.LocalizationService.Save(newLang); return Mapper.Map(newLang); } found.Mandatory = language.Mandatory; found.IsDefaultVariantLanguage = language.IsDefaultVariantLanguage; + found.FallbackLanguageId = language.FallbackLanguageId; + + string selectedFallbackLanguageCultureName; + if (DoesUpdatedFallbackLanguageCreateACircularPath(found, out selectedFallbackLanguageCultureName)) + { + ModelState.AddModelError("FallbackLanguage", "The selected fall back language '" + selectedFallbackLanguageCultureName + "' would create a circular path."); + throw new HttpResponseException(Request.CreateValidationErrorResponse(ModelState)); + } + Services.LocalizationService.Save(found); return Mapper.Map(found); } - + + private bool DoesUpdatedFallbackLanguageCreateACircularPath(ILanguage language, out string selectedFallbackLanguageCultureName) + { + if (language.FallbackLanguageId.HasValue == false) + { + selectedFallbackLanguageCultureName = string.Empty; + return false; + } + + var languages = Services.LocalizationService.GetAllLanguages().ToArray(); + var fallbackLanguageId = language.FallbackLanguageId; + while (fallbackLanguageId.HasValue) + { + if (fallbackLanguageId.Value == language.Id) + { + // We've found the current language in the path of fall back languages, so we have a circular path. + selectedFallbackLanguageCultureName = GetLanguageFromCollectionById(languages, fallbackLanguageId.Value).CultureName; + return true; + } + + fallbackLanguageId = GetLanguageFromCollectionById(languages, fallbackLanguageId.Value).FallbackLanguageId; + } + + selectedFallbackLanguageCultureName = string.Empty; + return false; + } + + private static ILanguage GetLanguageFromCollectionById(IEnumerable languages, int id) + { + return languages.Single(x => x.Id == id); + } } } diff --git a/src/Umbraco.Web/Models/ContentEditing/Language.cs b/src/Umbraco.Web/Models/ContentEditing/Language.cs index f78d2bd28f..7693ee836e 100644 --- a/src/Umbraco.Web/Models/ContentEditing/Language.cs +++ b/src/Umbraco.Web/Models/ContentEditing/Language.cs @@ -24,5 +24,8 @@ namespace Umbraco.Web.Models.ContentEditing [DataMember(Name = "isMandatory")] public bool Mandatory { get; set; } + + [DataMember(Name = "fallbackLanguageId")] + public int? FallbackLanguageId { get; set; } } } diff --git a/src/Umbraco.Web/Models/PublishedContent/PublishedValueFallback.cs b/src/Umbraco.Web/Models/PublishedContent/PublishedValueFallback.cs index 47e4b3d872..562b8e393b 100644 --- a/src/Umbraco.Web/Models/PublishedContent/PublishedValueFallback.cs +++ b/src/Umbraco.Web/Models/PublishedContent/PublishedValueFallback.cs @@ -11,45 +11,45 @@ namespace Umbraco.Web.Models.PublishedContent // kinda reproducing what was available in v7 /// - public object GetValue(IPublishedProperty property, string culture, string segment, object defaultValue) + public virtual object GetValue(IPublishedProperty property, string culture, string segment, object defaultValue) { // no fallback here return defaultValue; } /// - public T GetValue(IPublishedProperty property, string culture, string segment, T defaultValue) + public virtual T GetValue(IPublishedProperty property, string culture, string segment, T defaultValue) { // no fallback here return defaultValue; } /// - public object GetValue(IPublishedElement content, string alias, string culture, string segment, object defaultValue) + public virtual object GetValue(IPublishedElement content, string alias, string culture, string segment, object defaultValue) { // no fallback here return defaultValue; } /// - public T GetValue(IPublishedElement content, string alias, string culture, string segment, T defaultValue) + public virtual T GetValue(IPublishedElement content, string alias, string culture, string segment, T defaultValue) { // no fallback here return defaultValue; } /// - public object GetValue(IPublishedContent content, string alias, string culture, string segment, object defaultValue, bool recurse) + public virtual object GetValue(IPublishedContent content, string alias, string culture, string segment, object defaultValue, bool recurse, PublishedValueFallbackPriority fallbackPriority) { // no fallback here if (!recurse) return defaultValue; // is that ok? - return GetValue(content, alias, culture, segment, defaultValue, recurse); + return GetValue(content, alias, culture, segment, defaultValue, true, fallbackPriority); } /// - public T GetValue(IPublishedContent content, string alias, string culture, string segment, T defaultValue, bool recurse) + public virtual T GetValue(IPublishedContent content, string alias, string culture, string segment, T defaultValue, bool recurse, PublishedValueFallbackPriority fallbackPriority) { // no fallback here if (!recurse) return defaultValue; diff --git a/src/Umbraco.Web/Models/PublishedContent/PublishedValueLanguageFallback.cs b/src/Umbraco.Web/Models/PublishedContent/PublishedValueLanguageFallback.cs new file mode 100644 index 0000000000..d6e8db83f4 --- /dev/null +++ b/src/Umbraco.Web/Models/PublishedContent/PublishedValueLanguageFallback.cs @@ -0,0 +1,218 @@ +using Umbraco.Core.Models; +using Umbraco.Core.Models.PublishedContent; +using Umbraco.Core.Services; + +namespace Umbraco.Web.Models.PublishedContent +{ + /// + /// Provides a default implementation for that allows + /// for use of fall-back languages + /// + /// + /// Inherits from that implments what was available in v7. + /// + public class PublishedValueLanguageFallback : PublishedValueFallback + { + private readonly ILocalizationService _localizationService; + + public PublishedValueLanguageFallback(ServiceContext services) + { + _localizationService = services.LocalizationService; + } + + /// + public override object GetValue(IPublishedProperty property, string culture, string segment, object defaultValue) + { + object value; + if (TryGetValueFromFallbackLanguage(property, culture, segment, defaultValue, out value)) + { + return value; + } + + return base.GetValue(property, culture, segment, defaultValue); + } + + /// + public override T GetValue(IPublishedProperty property, string culture, string segment, T defaultValue) + { + T value; + if (TryGetValueFromFallbackLanguage(property, culture, segment, defaultValue, out value)) + { + return value; + } + + return base.GetValue(property, culture, segment, defaultValue); + } + + /// + public override object GetValue(IPublishedElement content, string alias, string culture, string segment, object defaultValue) + { + object value; + if (TryGetValueFromFallbackLanguage(content, alias, culture, segment, defaultValue, out value)) + { + return value; + } + + return base.GetValue(content, alias, culture, segment, defaultValue); + } + + /// + public override T GetValue(IPublishedElement content, string alias, string culture, string segment, T defaultValue) + { + T value; + if (TryGetValueFromFallbackLanguage(content, alias, culture, segment, defaultValue, out value)) + { + return value; + } + + return base.GetValue(content, alias, culture, segment, defaultValue); + } + + /// + public override object GetValue(IPublishedContent content, string alias, string culture, string segment, object defaultValue, bool recurse, PublishedValueFallbackPriority fallbackPriority) + { + return GetValue(content, alias, culture, segment, defaultValue, recurse, fallbackPriority); + } + + /// + public override T GetValue(IPublishedContent content, string alias, string culture, string segment, T defaultValue, bool recurse, PublishedValueFallbackPriority fallbackPriority) + { + if (fallbackPriority == PublishedValueFallbackPriority.RecursiveTree) + { + var result = base.GetValue(content, alias, culture, segment, defaultValue, recurse, PublishedValueFallbackPriority.RecursiveTree); + if (ValueIsNotNullEmptyOrDefault(result, defaultValue)) + { + // We've prioritised recursive tree search and found a value, so can return it. + return result; + } + + if (TryGetValueFromFallbackLanguage(content, alias, culture, segment, defaultValue, recurse, out result)) + { + return result; + } + + return defaultValue; + } + + if (fallbackPriority == PublishedValueFallbackPriority.FallbackLanguage) + { + T result; + if (TryGetValueFromFallbackLanguage(content, alias, culture, segment, defaultValue, recurse, out result)) + { + return result; + } + } + + // No language fall back content found, so use base implementation + return base.GetValue(content, alias, culture, segment, defaultValue, recurse, fallbackPriority); + } + + private bool TryGetValueFromFallbackLanguage(IPublishedProperty property, string culture, string segment, T defaultValue, out T value) + { + if (string.IsNullOrEmpty(culture)) + { + value = defaultValue; + return false; + } + + var language = _localizationService.GetLanguageByIsoCode(culture); + if (language.FallbackLanguageId.HasValue == false) + { + value = defaultValue; + return false; + } + + var fallbackLanguageId = language.FallbackLanguageId; + while (fallbackLanguageId.HasValue) + { + var fallbackLanguage = GetLanguageById(fallbackLanguageId.Value); + value = property.Value(fallbackLanguage.IsoCode, segment, defaultValue); + if (ValueIsNotNullEmptyOrDefault(value, defaultValue)) + { + return true; + } + + fallbackLanguageId = fallbackLanguage.FallbackLanguageId; + } + + value = defaultValue; + return false; + } + + private bool TryGetValueFromFallbackLanguage(IPublishedElement content, string alias, string culture, string segment, T defaultValue, out T value) + { + if (string.IsNullOrEmpty(culture)) + { + value = defaultValue; + return false; + } + + var language = _localizationService.GetLanguageByIsoCode(culture); + if (language.FallbackLanguageId.HasValue == false) + { + value = defaultValue; + return false; + } + + var fallbackLanguageId = language.FallbackLanguageId; + while (fallbackLanguageId.HasValue) + { + var fallbackLanguage = GetLanguageById(fallbackLanguageId.Value); + value = content.Value(alias, fallbackLanguage.IsoCode, segment, defaultValue); + if (ValueIsNotNullEmptyOrDefault(value, defaultValue)) + { + return true; + } + + fallbackLanguageId = fallbackLanguage.FallbackLanguageId; + } + + value = defaultValue; + return false; + } + + private bool TryGetValueFromFallbackLanguage(IPublishedContent content, string alias, string culture, string segment, T defaultValue, bool recurse, out T value) + { + if (string.IsNullOrEmpty(culture)) + { + value = defaultValue; + return false; + } + + var language = _localizationService.GetLanguageByIsoCode(culture); + if (language.FallbackLanguageId.HasValue == false) + { + value = defaultValue; + return false; + } + + var fallbackLanguageId = language.FallbackLanguageId; + while (fallbackLanguageId.HasValue) + { + var fallbackLanguage = GetLanguageById(fallbackLanguageId.Value); + value = content.Value(alias, fallbackLanguage.IsoCode, segment, defaultValue, recurse); + if (ValueIsNotNullEmptyOrDefault(value, defaultValue)) + { + return true; + } + + fallbackLanguageId = fallbackLanguage.FallbackLanguageId; + } + + value = defaultValue; + return false; + } + + private ILanguage GetLanguageById(int id) + { + return _localizationService.GetLanguageById(id); + } + + private static bool ValueIsNotNullEmptyOrDefault(T value, T defaultValue) + { + return value != null && + string.IsNullOrEmpty(value.ToString()) == false && + value.Equals(defaultValue) == false; + } + } +} diff --git a/src/Umbraco.Web/PublishedContentExtensions.cs b/src/Umbraco.Web/PublishedContentExtensions.cs index d3ff363156..54eae4b215 100644 --- a/src/Umbraco.Web/PublishedContentExtensions.cs +++ b/src/Umbraco.Web/PublishedContentExtensions.cs @@ -152,8 +152,9 @@ namespace Umbraco.Web /// The property alias. /// The variation language. /// The variation segment. - /// A value indicating whether to recurse. /// The default value. + /// A value indicating whether to recurse. + /// Flag indicating priority order of fallback paths in cases when content does not exist and a fall back method is used. /// The value of the content's property identified by the alias, if it exists, otherwise a default value. /// /// Recursively means: walking up the tree from , get the first value that can be found. @@ -162,14 +163,14 @@ namespace Umbraco.Web /// If eg a numeric property wants to default to 0 when value source is empty, this has to be done in the converter. /// The alias is case-insensitive. /// - public static object Value(this IPublishedContent content, string alias, string culture = null, string segment = null, object defaultValue = default, bool recurse = false) + public static object Value(this IPublishedContent content, string alias, string culture = null, string segment = null, object defaultValue = default, bool recurse = false, PublishedValueFallbackPriority fallbackPriority = PublishedValueFallbackPriority.RecursiveTree) { var property = content.GetProperty(alias); if (property != null && property.HasValue(culture, segment)) return property.GetValue(culture, segment); - return PublishedValueFallback.GetValue(content, alias, culture, segment, defaultValue, recurse); + return PublishedValueFallback.GetValue(content, alias, culture, segment, defaultValue, recurse, fallbackPriority); } #endregion @@ -186,6 +187,7 @@ namespace Umbraco.Web /// The variation segment. /// The default value. /// A value indicating whether to recurse. + /// Flag indicating priority order of fallback paths in cases when content does not exist and a fall back method is used. /// The value of the content's property identified by the alias, converted to the specified type. /// /// Recursively means: walking up the tree from , get the first value that can be found. @@ -194,18 +196,18 @@ namespace Umbraco.Web /// If eg a numeric property wants to default to 0 when value source is empty, this has to be done in the converter. /// The alias is case-insensitive. /// - public static T Value(this IPublishedContent content, string alias, string culture = null, string segment = null, T defaultValue = default, bool recurse = false) + public static T Value(this IPublishedContent content, string alias, string culture = null, string segment = null, T defaultValue = default, bool recurse = false, PublishedValueFallbackPriority fallbackPriority = PublishedValueFallbackPriority.RecursiveTree) { var property = content.GetProperty(alias); if (property != null && property.HasValue(culture, segment)) return property.Value(culture, segment); - return PublishedValueFallback.GetValue(content, alias, culture, segment, defaultValue, recurse); + return PublishedValueFallback.GetValue(content, alias, culture, segment, defaultValue, recurse, fallbackPriority); } // fixme - .Value() refactoring - in progress - public static IHtmlString Value(this IPublishedContent content, string aliases, Func format, string alt = "", bool recurse = false) + public static IHtmlString Value(this IPublishedContent content, string aliases, Func format, string alt = "", bool recurse = false, PublishedValueFallbackPriority fallbackPriority = PublishedValueFallbackPriority.RecursiveTree) { var aliasesA = aliases.Split(','); if (aliasesA.Length == 0) diff --git a/src/Umbraco.Web/Runtime/WebRuntimeComponent.cs b/src/Umbraco.Web/Runtime/WebRuntimeComponent.cs index 03ba763527..78ddb935a0 100644 --- a/src/Umbraco.Web/Runtime/WebRuntimeComponent.cs +++ b/src/Umbraco.Web/Runtime/WebRuntimeComponent.cs @@ -199,7 +199,7 @@ namespace Umbraco.Web.Runtime composition.Container.Register(_ => GlobalHost.ConnectionManager.GetHubContext(), new PerContainerLifetime()); // register properties fallback - composition.Container.RegisterSingleton(); + composition.Container.RegisterSingleton(); } internal void Initialize( diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index 0c76aedc80..af2170123a 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -263,6 +263,7 @@ +