diff --git a/src/Umbraco.Core/Configuration/Models/CharItem.cs b/src/Umbraco.Core/Configuration/Models/CharItem.cs new file mode 100644 index 0000000000..e269e0a83e --- /dev/null +++ b/src/Umbraco.Core/Configuration/Models/CharItem.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Configuration.UmbracoSettings; + +namespace Umbraco.Cms.Core.Configuration.Models +{ + public class CharItem : IChar + { + /// + /// The character to replace + /// + public string Char { get; set; } + + /// + /// The replacement character + /// + public string Replacement { get; set; } + } +} diff --git a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs index ee223b36c6..051c31dc26 100644 --- a/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/RequestHandlerSettings.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System; using System.Collections.Generic; using System.ComponentModel; using Umbraco.Cms.Core.Configuration.UmbracoSettings; @@ -16,33 +17,34 @@ namespace Umbraco.Cms.Core.Configuration.Models { internal const bool StaticAddTrailingSlash = true; internal const string StaticConvertUrlsToAscii = "try"; + internal const bool StaticEnableDefaultCharReplacements = true; internal static readonly CharItem[] DefaultCharCollection = { - new CharItem { Char = " ", Replacement = "-" }, - new CharItem { Char = "\"", Replacement = string.Empty }, - new CharItem { Char = "'", Replacement = string.Empty }, - new CharItem { Char = "%", Replacement = string.Empty }, - new CharItem { Char = ".", Replacement = string.Empty }, - new CharItem { Char = ";", Replacement = string.Empty }, - new CharItem { Char = "/", Replacement = string.Empty }, - new CharItem { Char = "\\", Replacement = string.Empty }, - new CharItem { Char = ":", Replacement = string.Empty }, - new CharItem { Char = "#", Replacement = string.Empty }, - new CharItem { Char = "+", Replacement = "plus" }, - new CharItem { Char = "*", Replacement = "star" }, - new CharItem { Char = "&", Replacement = string.Empty }, - new CharItem { Char = "?", Replacement = string.Empty }, - new CharItem { Char = "æ", Replacement = "ae" }, - new CharItem { Char = "ä", Replacement = "ae" }, - new CharItem { Char = "ø", Replacement = "oe" }, - new CharItem { Char = "ö", Replacement = "oe" }, - new CharItem { Char = "å", Replacement = "aa" }, - new CharItem { Char = "ü", Replacement = "ue" }, - new CharItem { Char = "ß", Replacement = "ss" }, - new CharItem { Char = "|", Replacement = "-" }, - new CharItem { Char = "<", Replacement = string.Empty }, - new CharItem { Char = ">", Replacement = string.Empty } + new () { Char = " ", Replacement = "-" }, + new () { Char = "\"", Replacement = string.Empty }, + new () { Char = "'", Replacement = string.Empty }, + new () { Char = "%", Replacement = string.Empty }, + new () { Char = ".", Replacement = string.Empty }, + new () { Char = ";", Replacement = string.Empty }, + new () { Char = "/", Replacement = string.Empty }, + new () { Char = "\\", Replacement = string.Empty }, + new () { Char = ":", Replacement = string.Empty }, + new () { Char = "#", Replacement = string.Empty }, + new () { Char = "+", Replacement = "plus" }, + new () { Char = "*", Replacement = "star" }, + new () { Char = "&", Replacement = string.Empty }, + new () { Char = "?", Replacement = string.Empty }, + new () { Char = "æ", Replacement = "ae" }, + new () { Char = "ä", Replacement = "ae" }, + new () { Char = "ø", Replacement = "oe" }, + new () { Char = "ö", Replacement = "oe" }, + new () { Char = "å", Replacement = "aa" }, + new () { Char = "ü", Replacement = "ue" }, + new () { Char = "ß", Replacement = "ss" }, + new () { Char = "|", Replacement = "-" }, + new () { Char = "<", Replacement = string.Empty }, + new () { Char = ">", Replacement = string.Empty } }; /// @@ -67,41 +69,21 @@ namespace Umbraco.Cms.Core.Configuration.Models /// public bool ShouldTryConvertUrlsToAscii => ConvertUrlsToAscii.InvariantEquals("try"); - // We need to special handle ":", as this character is special in keys - - // TODO: implement from configuration - - //// var collection = _configuration.GetSection(Prefix + "CharCollection").GetChildren() - //// .Select(x => new CharItem() - //// { - //// Char = x.GetValue("Char"), - //// Replacement = x.GetValue("Replacement"), - //// }).ToArray(); - - //// if (collection.Any() || _configuration.GetSection("Prefix").GetChildren().Any(x => - //// x.Key.Equals("CharCollection", StringComparison.OrdinalIgnoreCase))) - //// { - //// return collection; - //// } - - //// return DefaultCharCollection; + /// + /// Disable all default character replacements + /// + [DefaultValue(StaticEnableDefaultCharReplacements)] + public bool EnableDefaultCharReplacements { get; set; } = StaticEnableDefaultCharReplacements; /// - /// Gets or sets a value for the default character collection for replacements. + /// Add additional character replacements, or override defaults /// - /// WB-TODO + [Obsolete("Use the GetCharReplacements extension method in the Umbraco.Extensions namespace instead. Scheduled for removal in V11")] public IEnumerable CharCollection { get; set; } = DefaultCharCollection; /// - /// Defines a character replacement. + /// Add additional character replacements, or override defaults /// - public class CharItem : IChar - { - /// - public string Char { get; set; } - - /// - public string Replacement { get; set; } - } + public IEnumerable UserDefinedCharCollection { get; set; } } } diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs new file mode 100644 index 0000000000..a916febb93 --- /dev/null +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/CharacterReplacementEqualityComparer.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Umbraco.Cms.Core.Configuration.Models; + +namespace Umbraco.Cms.Core.Configuration.UmbracoSettings +{ + public class CharacterReplacementEqualityComparer : IEqualityComparer + { + public bool Equals(IChar x, IChar y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (x is null) + { + return false; + } + + if (y is null) + { + return false; + } + + if (x.GetType() != y.GetType()) + { + return false; + } + + return x.Char == y.Char && x.Replacement == y.Replacement; + } + + public int GetHashCode(IChar obj) + { + unchecked + { + return ((obj.Char != null ? obj.Char.GetHashCode() : 0) * 397) ^ (obj.Replacement != null ? obj.Replacement.GetHashCode() : 0); + } + } + } +} diff --git a/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs b/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs index 4073a12149..61e840245c 100644 --- a/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs +++ b/src/Umbraco.Core/Configuration/UmbracoSettings/IChar.cs @@ -1,8 +1,9 @@ -namespace Umbraco.Cms.Core.Configuration.UmbracoSettings +namespace Umbraco.Cms.Core.Configuration.UmbracoSettings { public interface IChar { string Char { get; } + string Replacement { get; } } } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs index 6ef87464e8..d1a8542688 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs @@ -1,9 +1,12 @@ using System; +using System.Collections.Generic; using System.Reflection; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Configuration.Models.Validation; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.DependencyInjection { @@ -76,6 +79,8 @@ namespace Umbraco.Cms.Core.DependencyInjection .AddUmbracoOptions() .AddUmbracoOptions(); + builder.Services.Configure(options => options.MergeReplacements(builder.Config)); + return builder; } } diff --git a/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs b/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs new file mode 100644 index 0000000000..e9e6618f8c --- /dev/null +++ b/src/Umbraco.Core/Extensions/RequestHandlerSettingsExtension.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Configuration.UmbracoSettings; + +namespace Umbraco.Extensions +{ + /// + /// Get concatenated user and default character replacements + /// taking into account + /// + public static class RequestHandlerSettingsExtension + { + /// + /// Get concatenated user and default character replacements + /// taking into account + /// + public static IEnumerable GetCharReplacements(this RequestHandlerSettings requestHandlerSettings) + { + if (requestHandlerSettings.EnableDefaultCharReplacements is false) + { + return requestHandlerSettings.UserDefinedCharCollection ?? Enumerable.Empty(); + } + + if (requestHandlerSettings.UserDefinedCharCollection == null || requestHandlerSettings.UserDefinedCharCollection.Any() is false) + { + return RequestHandlerSettings.DefaultCharCollection; + } + + return MergeUnique(requestHandlerSettings.UserDefinedCharCollection, RequestHandlerSettings.DefaultCharCollection); + } + + /// + /// Merges CharCollection and UserDefinedCharCollection, prioritizing UserDefinedCharCollection + /// + internal static void MergeReplacements(this RequestHandlerSettings requestHandlerSettings, IConfiguration configuration) + { + string sectionKey = $"{Constants.Configuration.ConfigRequestHandler}:"; + + IEnumerable charCollection = GetReplacements( + configuration, + $"{sectionKey}{nameof(RequestHandlerSettings.CharCollection)}"); + + IEnumerable userDefinedCharCollection = GetReplacements( + configuration, + $"{sectionKey}{nameof(requestHandlerSettings.UserDefinedCharCollection)}"); + + IEnumerable mergedCollection = MergeUnique(userDefinedCharCollection, charCollection); + + requestHandlerSettings.UserDefinedCharCollection = mergedCollection; + } + + private static IEnumerable GetReplacements(IConfiguration configuration, string key) + { + var replacements = new List(); + IEnumerable config = configuration.GetSection(key).GetChildren(); + + foreach (IConfigurationSection section in config) + { + var @char = section.GetValue(nameof(CharItem.Char)); + var replacement = section.GetValue(nameof(CharItem.Replacement)); + replacements.Add(new CharItem { Char = @char, Replacement = replacement }); + } + + return replacements; + } + + /// + /// Merges two IEnumerable of CharItem without any duplicates, items in priorityReplacements will override those in alternativeReplacements + /// + private static IEnumerable MergeUnique( + IEnumerable priorityReplacements, + IEnumerable alternativeReplacements) + { + var priorityReplacementsList = priorityReplacements.ToList(); + var alternativeReplacementsList = alternativeReplacements.ToList(); + + foreach (CharItem alternativeReplacement in alternativeReplacementsList) + { + foreach (CharItem priorityReplacement in priorityReplacementsList) + { + if (priorityReplacement.Char == alternativeReplacement.Char) + { + alternativeReplacement.Replacement = priorityReplacement.Replacement; + } + } + } + + return priorityReplacementsList.Union( + alternativeReplacementsList, + new CharacterReplacementEqualityComparer()); + } + } +} diff --git a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs index cf5e71a568..b0f0a9b003 100644 --- a/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs +++ b/src/Umbraco.Core/Strings/DefaultShortStringHelperConfig.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Configuration.UmbracoSettings; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Strings @@ -60,7 +61,9 @@ namespace Umbraco.Cms.Core.Strings /// The short string helper. public DefaultShortStringHelperConfig WithDefault(RequestHandlerSettings requestHandlerSettings) { - UrlReplaceCharacters = requestHandlerSettings.CharCollection + IEnumerable charCollection = requestHandlerSettings.GetCharReplacements(); + + UrlReplaceCharacters = charCollection .Where(x => string.IsNullOrEmpty(x.Char) == false) .ToDictionary(x => x.Char, x => x.Replacement); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs new file mode 100644 index 0000000000..f159ecbc85 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Configuration/Models/RequestHandlerSettingsTests.cs @@ -0,0 +1,90 @@ +using System.Linq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Configuration.Models +{ + [TestFixture] + public class RequestHandlerSettingsTests + { + [Test] + public void Given_CharCollection_With_DefaultEnabled_MergesCollection() + { + var userCollection = new CharItem[] + { + new () { Char = "test", Replacement = "replace" }, + new () { Char = "test2", Replacement = "replace2" } + }; + + + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); + + var expectedCollection = RequestHandlerSettings.DefaultCharCollection.ToList(); + expectedCollection.AddRange(userCollection); + + Assert.AreEqual(expectedCollection.Count, actual.Count); + Assert.That(actual, Is.EquivalentTo(expectedCollection)); + } + + [Test] + public void Given_CharCollection_With_DefaultDisabled_ReturnsUserCollection() + { + var userCollection = new CharItem[] + { + new () { Char = "test", Replacement = "replace" }, + new () { Char = "test2", Replacement = "replace2" } + }; + + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection, EnableDefaultCharReplacements = false }; + var actual = settings.GetCharReplacements().ToList(); + + Assert.AreEqual(userCollection.Length, actual.Count); + Assert.That(actual, Is.EquivalentTo(userCollection)); + } + + [Test] + public void Given_CharCollection_That_OverridesDefaultValues_ReturnsReplacements() + { + var userCollection = new CharItem[] + { + new () { Char = "%", Replacement = "percent" }, + new () { Char = ".", Replacement = "dot" } + }; + + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); + + Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length, actual.Count); + + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); + } + + [Test] + public void Given_CharCollection_That_OverridesDefaultValues_And_ContainsNew_ReturnsMergedWithReplacements() + { + var userCollection = new CharItem[] + { + new () { Char = "%", Replacement = "percent" }, + new () { Char = ".", Replacement = "dot" }, + new () { Char = "new", Replacement = "new" } + }; + + var settings = new RequestHandlerSettings { UserDefinedCharCollection = userCollection }; + var actual = settings.GetCharReplacements().ToList(); + + // Add 1 to the length, because we're expecting to only add one new one + Assert.AreEqual(RequestHandlerSettings.DefaultCharCollection.Length + 1, actual.Count); + + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "%" && x.Replacement == "percent")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "." && x.Replacement == "dot")); + Assert.That(actual, Has.Exactly(1).Matches(x => x.Char == "new" && x.Replacement == "new")); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "%" && x.Replacement == string.Empty)); + Assert.That(actual, Has.Exactly(0).Matches(x => x.Char == "." && x.Replacement == string.Empty)); + } + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs index 6f9ee481cc..b686aee278 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/DefaultShortStringHelperTestsWithoutSetup.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System; using System.Diagnostics; using System.Linq; using System.Text; @@ -19,7 +20,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Enumerable.Empty(), + CharCollection = Array.Empty(), + EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; @@ -45,7 +47,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Enumerable.Empty(), + CharCollection = Array.Empty(), + EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" }; @@ -339,7 +342,8 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.ShortStringHelper { var requestHandlerSettings = new RequestHandlerSettings() { - CharCollection = Enumerable.Empty(), + CharCollection = Array.Empty(), + EnableDefaultCharReplacements = false, ConvertUrlsToAscii = "false" };