diff --git a/docs/content-naming-provider.md b/docs/content-naming-provider.md index e3f957792..7c3c66b81 100644 --- a/docs/content-naming-provider.md +++ b/docs/content-naming-provider.md @@ -33,6 +33,160 @@ We offer the following predefined naming providers. You may choose either one of Duplicated 'ReplacementChar' characters are replaced by a single character (so after the conversion the name may contain less characters than the display name). This conversion is very simple but there is a chance of non-unique name creation. The original file extension will be kept. +### Optional name normalization + +The following independent `sensenet:contentNaming` switches all default to `false`. +They affect `CharReplacementContentNamingProvider` only. + +| Setting | Effect | +| --- | --- | +| `ApplyCustomReplacements` | Applies the configured literal replacement pairs in array order. | +| `RemoveDiacritics` | Removes decomposable Unicode diacritics using canonical decomposition. | +| `Transliterate` | Uses AnyAscii 0.3.3 to transliterate Unicode text to ASCII. | +| `Lowercase` | Converts letters using invariant casing. | + +The pipeline is custom replacements, diacritic removal, transliteration, lowercase, +then the existing invalid-character replacement. Each step can be enabled separately. +When all four switches are absent or `false`, name generation remains unchanged. +Transliteration itself removes many diacritics. To preserve accents, disable both +`RemoveDiacritics` and `Transliterate` and avoid custom replacements that remove them. +A Settings veto disables its step; other enabled steps can still have overlapping effects. + +The Docker webapps include prepared replacement pairs in their base `appsettings.json` +files. All four switches are explicitly `false`; the prepared list has no effect until +`ApplyCustomReplacements` is enabled. These are configuration values, not built-in +fallbacks in the provider or the configuration reader: + +```json +{ + "sensenet": { + "contentNaming": { + "RemoveDiacritics": false, + "Lowercase": false, + "Transliterate": false, + "ApplyCustomReplacements": false, + "ReplacementSources": [ + "ä", "Ä", "ö", "Ö", "ü", "Ü", "ß", "ẞ", + "æ", "Æ", "œ", "Œ", "ø", "Ø", "ł", "Ł", + "đ", "Đ", "ð", "Ð", "þ", "Þ", "ħ", "Ħ", + "ı", "ij", "IJ", "ŋ", "Ŋ", "ŧ", "Ŧ", "ſ", "ŀ", "Ŀ", + "&", " " + ], + "ReplacementTargets": [ + "ae", "AE", "oe", "OE", "ue", "UE", "ss", "SS", + "ae", "AE", "oe", "OE", "o", "O", "l", "L", + "d", "D", "d", "D", "th", "TH", "h", "H", + "i", "ij", "IJ", "n", "N", "t", "T", "s", "l", "L", + "and", "-" + ] + } + } +} +``` + +The list covers common additional Latin letters used in European languages and +includes German-style `ä -> ae`, `ö -> oe`, `ü -> ue`, `ß -> ss`, with uppercase +counterparts, plus `& -> and` and spaces to hyphens. It is a starting point to +customize for the repository's languages, not automatic language detection. +Every configured pair applies to every input when custom replacement is enabled. + +- For Hungarian text, enable `RemoveDiacritics` and leave + `ApplyCustomReplacements` disabled. No custom Hungarian accent pairs are needed. +- For German text, enable `ApplyCustomReplacements`. `RemoveDiacritics` can also + be enabled for other decomposable accents: custom replacement runs first, so + the German `ae`/`oe`/`ue` results are preserved. +- For mixed-language content, the operator chooses the switches and customizes + the list. For example, the prepared `ö -> oe` and `ü -> ue` pairs also change + Hungarian `tükör` to `tuekoer` when custom replacement is enabled. + +With only `ApplyCustomReplacements` enabled, `Fähre & Straße schön kühl` +becomes `Faehre-and-Strasse-schoen-kuehl`. The uppercase input `ÄÖÜẞ` becomes +`AEOEUESS` with the prepared pairs. + +With `RemoveDiacritics` and `Lowercase` enabled and custom replacement disabled, +`Árvíztűrő tükörfúrógép` becomes `arvizturo tukorfurogep`. Spaces remain under the +default invalid-character pattern; the existing `InvalidNameCharsPattern` and +`ReplacementChar` can be configured to replace whitespace with hyphens separately. +If custom replacement is enabled as well, the prepared German pairs instead +produce `arvizturo-tuekoerfurogep`. + +`RemoveDiacritics` alone maps `ä/ö/ü` to `a/o/u`; it does not change `ß` to `ss`. +Other scripts can be handled by the separate `Transliterate` option. +The original stored `DisplayName` is preserved. + +The replacement arrays are paired by index. Entries are case-sensitive literal +strings, not regular expressions, and can contain multiple characters. An empty +target string deletes a match. Sources must be non-empty strings and both arrays +must have the same length; invalid pairs throw when custom replacement is enabled. +If no replacement entries are loaded, the custom step does nothing. +Use string values, not JSON null values. +Pairs run in order and may cascade: `a -> b`, then `b -> c`, turns `ab` into `cc`. +Custom replacement runs first, so `ä -> ae` can override the usual `ä -> a`. + +For Docker, the four switches are environment variables such as +`sensenet__contentNaming__Transliterate=true`. Array elements use consecutive, +zero-based indexes, for example `sensenet__contentNaming__ReplacementSources__0=ä` +and `sensenet__contentNaming__ReplacementTargets__0=ae`. Do not leave gaps: array +loading stops at the first missing index. Configuration providers merge array +entries by index: a shorter array in a later provider does not clear remaining +entries from the base configuration. Edit the complete paired list in the base +`appsettings.json` when removing prepared pairs. The host package/image must contain +these features; changing host configuration or replacement arrays requires a restart. + +### Runtime Settings overrides + +Operators can disable any configured step without restarting the repository by +saving the global `/Root/System/Settings/ContentNaming.settings` content. Use the +existing Settings content editing/management APIs and permissions. For example, +this disables only transliteration: + +```json +{ + "Transliterate": false +} +``` + +Use the same four switch names in the settings file. Settings are checked during +name generation on the running repository and use the existing Settings cache +invalidation. The rules are: + +| Host config | Settings value | Effective behavior | +| --- | --- | --- | +| `false` | Any value or absent | Disabled | +| `true` | Absent or `true` | Enabled | +| `true` | `false` | Disabled | + +Settings cannot enable a feature disabled by host configuration. Removing the +override restores the configured behavior. Replacement arrays and the existing +`InvalidNameCharsPattern`/`ReplacementChar` are not overridden by these settings. +This is a global override; the naming API does not receive a content context path. + +### Preserved behavior and Unicode scope + +The stored `DisplayName`, input parameters, original extension handling, and name +validation are unchanged. An extension retained from `originalName` is appended +unchanged, even with normalization enabled (`NEW.TXT` with original name `old.TXT` +becomes `new.TXT` with `Lowercase`). Changing naming configuration or Settings does +not rename existing content; this is the standard content naming behavior. + +The existing `InvalidNameCharsPattern` and `ReplacementChar` still control invalid +character and whitespace replacement. Custom replacements can replace a space +with `-` without changing the global validation pattern. Changing the existing +invalid-character pattern separately still affects repository-wide Name/Path validation. + +`RemoveDiacritics` removes combining marks in all scripts after canonical +decomposition. It leaves `æ`, `ø`, `ß` and non-Latin letters that have no relevant +decomposition. `Transliterate` additionally handles such Latin letters (`Æ -> Ae`, +`Œ -> Oe`, `ß -> ss`) and other writing systems and symbols. AnyAscii works without +language context; its output is not guaranteed to match language-specific spelling. +It can expand symbols, introduce punctuation, or remove unsupported characters. +The existing invalid-character replacement runs afterward. Transliteration does +not lowercase ASCII letters; the separate `Lowercase` option controls that. + +Different input names can produce the same result or an empty name. Existing +duplicate-name handling and validation apply. AnyAscii is distributed under the +ISC license; its notice is included in the package and copied to output/publish. + ## Underscore5FContentNamingProvider Contains a customized *GenerateNameFromDisplayName* method that encodes the display name. The encoding works with the standard UrlEncode .Net method, but the percent sign ('%') will be replaced with an underscore ('_') character. The names that are generated from unique names are guaranteed to remain unique. After the conversion, the name may contain more characters than the input name. The original file extension will be kept. Invalid characters are configurable in the **sensenet/contentNaming** section in the web.config. diff --git a/docs/content-naming.md b/docs/content-naming.md index a31874606..29ae74ded 100644 --- a/docs/content-naming.md +++ b/docs/content-naming.md @@ -122,6 +122,12 @@ Pattern must start with '[' and end with ']'. --> ``` - **InvalidNameCharsPattern**: a regular expression that defines the invalid characters a Content Name may not contain. These invalid characters are automatically encoded (or replaced) during converting a display name to a name, depending on the configured [ContentNamingProvider](content-naming-provider.md). If the Name is not autogenerated (for example for Files when DisplayName Field Control is not visible in the Content View) and the user inputs a name that contains invalid characters, an error message will be displayed upon trying to save the Content. The regular expression may contain characterset with a negating clause ([^...]) thus defining allowed characters instead of invalid ones, or define invalid characters simply ([...]). In both cases the regular expression MUST start with '[' and end with ']'. - **ReplacementChar**: defines the character used to replace invalid characters. +- **RemoveDiacritics**: optional diacritic removal for generated names, `false` by default. See [optional name normalization](content-naming-provider.md#optional-name-normalization) for configuration and behavior. +- **Lowercase**: optional invariant lowercasing of generated names, `false` by default. See [optional name normalization](content-naming-provider.md#optional-name-normalization). +- **Transliterate**: optional AnyAscii Unicode-to-ASCII transliteration, `false` by default. +- **ApplyCustomReplacements**: optional ordered literal replacements, `false` by default. `ReplacementSources` and `ReplacementTargets` are paired string arrays. + +All four normalization switches can be disabled at runtime through global `ContentNaming.settings`; Settings cannot enable a switch disabled in host config. See [runtime Settings overrides](content-naming-provider.md#runtime-settings-overrides). > Note that changing InvalidNameCharsPattern will affect path validation logic in the whole Content Repository. It is therefore desired to change validation messages when changing invalid characters pattern, see [Invalid names and error messages](#Invalid-names-and-error-messages) below. diff --git a/src/ContentRepository/ContentNamingProvider.cs b/src/ContentRepository/ContentNamingProvider.cs index b53c17196..2a885b0fd 100644 --- a/src/ContentRepository/ContentNamingProvider.cs +++ b/src/ContentRepository/ContentNamingProvider.cs @@ -1,10 +1,12 @@ -using SenseNet.ApplicationModel; +using AnyAscii; +using SenseNet.ApplicationModel; using SenseNet.ContentRepository.Schema; using SenseNet.ContentRepository.Storage; using SenseNet.ContentRepository.Storage.Data; using SenseNet.Diagnostics; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -380,6 +382,18 @@ public override string GenerateNameFromDisplayName(string originalName, string d // convert the displayname to name: if extensions are equal, only filename is to be transformed. extension is added at the end var nameToConvert = origext == newext ? displayNameWithoutExtension : displayName; + if (IsEnabled(nameof(ContentNaming.ApplyCustomReplacements), ContentNaming.ApplyCustomReplacements)) + nameToConvert = ApplyCustomReplacements(nameToConvert); + + if (IsEnabled(nameof(ContentNaming.RemoveDiacritics), ContentNaming.RemoveDiacritics)) + nameToConvert = RemoveDiacritics(nameToConvert); + + if (IsEnabled(nameof(ContentNaming.Transliterate), ContentNaming.Transliterate)) + nameToConvert = nameToConvert.Transliterate(); + + if (IsEnabled(nameof(ContentNaming.Lowercase), ContentNaming.Lowercase)) + nameToConvert = nameToConvert.ToLowerInvariant(); + string newName = RemoveInvalidCharacters(nameToConvert); // attach original extension @@ -387,6 +401,42 @@ public override string GenerateNameFromDisplayName(string originalName, string d return newName.TrimEnd('.', replacementChar); } + private static bool IsEnabled(string optionName, bool configuredValue) + { + // Repository settings can veto a configured feature, never enable a disabled one. + return configuredValue && (!Repository.Started() || + Settings.GetValue("ContentNaming", optionName, defaultValue: true)); + } + + private static string ApplyCustomReplacements(string name) + { + var sources = ContentNaming.ReplacementSources; + var targets = ContentNaming.ReplacementTargets; + if (sources.Length != targets.Length || sources.Any(string.IsNullOrEmpty) || targets.Any(t => t == null)) + throw new InvalidOperationException("ContentNaming ReplacementSources and ReplacementTargets must have " + + "the same length, with non-empty sources and non-null targets."); + + for (var i = 0; i < sources.Length; i++) + name = name.Replace(sources[i], targets[i], StringComparison.Ordinal); + + return name; + } + + private static string RemoveDiacritics(string name) + { + var result = new StringBuilder(name.Length); + foreach (var rune in name.Normalize(NormalizationForm.FormD).EnumerateRunes()) + { + var category = Rune.GetUnicodeCategory(rune); + if (category != UnicodeCategory.NonSpacingMark && + category != UnicodeCategory.SpacingCombiningMark && + category != UnicodeCategory.EnclosingMark) + result.Append(rune.ToString()); + } + + return result.ToString().Normalize(NormalizationForm.FormC); + } + private string RemoveInvalidCharacters(string name) { name = Regex.Replace(name, ContentNaming.InvalidNameCharsPattern, replacementString); diff --git a/src/ContentRepository/SenseNet.ContentRepository.csproj b/src/ContentRepository/SenseNet.ContentRepository.csproj index 71f97a777..83c15e111 100644 --- a/src/ContentRepository/SenseNet.ContentRepository.csproj +++ b/src/ContentRepository/SenseNet.ContentRepository.csproj @@ -36,6 +36,7 @@ + @@ -56,4 +57,10 @@ + + + + \ No newline at end of file diff --git a/src/ContentRepository/ThirdPartyNotices/AnyAscii.txt b/src/ContentRepository/ThirdPartyNotices/AnyAscii.txt new file mode 100644 index 000000000..f7f0dcc94 --- /dev/null +++ b/src/ContentRepository/ThirdPartyNotices/AnyAscii.txt @@ -0,0 +1,17 @@ +AnyAscii 0.3.3 - https://github.com/anyascii/anyascii + +ISC License + +Copyright (c) 2020-2025, Hunter WB + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/Storage/Configuration/ContentNaming.cs b/src/Storage/Configuration/ContentNaming.cs index 14c650760..abb042ec1 100644 --- a/src/Storage/Configuration/ContentNaming.cs +++ b/src/Storage/Configuration/ContentNaming.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Collections.Generic; namespace SenseNet.Configuration { @@ -18,5 +19,55 @@ public class ContentNaming : SnConfig public static char ReplacementChar { get; internal set; } = GetString(SECTIONNAME, "ReplacementChar", "-").FirstOrDefault(); + + /// + /// Removes decomposable Unicode diacritics when generating a name from a display name. + /// Default: false. + /// + public static bool RemoveDiacritics { get; internal set; } = + GetValue(SECTIONNAME, nameof(RemoveDiacritics), false); + + /// + /// Converts generated names to lowercase using invariant casing. + /// The original extension is preserved. Default: false. + /// + public static bool Lowercase { get; internal set; } = + GetValue(SECTIONNAME, nameof(Lowercase), false); + + /// + /// Transliterates generated names to ASCII using AnyAscii. Default: false. + /// + public static bool Transliterate { get; internal set; } = + GetValue(SECTIONNAME, nameof(Transliterate), false); + + /// + /// Applies the ordered ReplacementSources/ReplacementTargets pairs to generated names. Default: false. + /// + public static bool ApplyCustomReplacements { get; internal set; } = + GetValue(SECTIONNAME, nameof(ApplyCustomReplacements), false); + + /// + /// Literal, case-sensitive source strings for custom replacements, in application order. + /// + public static string[] ReplacementSources { get; internal set; } = + GetReplacementArray(nameof(ReplacementSources)); + + /// + /// Replacement strings paired by index with ReplacementSources. Empty strings remove matches. + /// + public static string[] ReplacementTargets { get; internal set; } = + GetReplacementArray(nameof(ReplacementTargets)); + + private static string[] GetReplacementArray(string key) + { + var values = new List(); + for (var index = 0; ; index++) + { + var value = GetString(SECTIONNAME, key + ":" + index); + if (value == null) + return values.ToArray(); + values.Add(value); + } + } } } diff --git a/src/Tests/SenseNet.ContentRepository.Tests/ContentNamingFeatureTests.cs b/src/Tests/SenseNet.ContentRepository.Tests/ContentNamingFeatureTests.cs new file mode 100644 index 000000000..be6ab43da --- /dev/null +++ b/src/Tests/SenseNet.ContentRepository.Tests/ContentNamingFeatureTests.cs @@ -0,0 +1,146 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SenseNet.Configuration; +using SenseNet.Testing; + +namespace SenseNet.ContentRepository.Tests +{ + [TestClass] + [DoNotParallelize] + public class ContentNamingFeatureTests + { + [TestMethod] + public void NewOptions_AreDisabledAndMappingsEmptyByDefault() + { + Assert.IsFalse(ContentNaming.Transliterate); + Assert.IsFalse(ContentNaming.ApplyCustomReplacements); + Assert.AreEqual(0, ContentNaming.ReplacementSources.Length); + Assert.AreEqual(0, ContentNaming.ReplacementTargets.Length); + } + + [DataTestMethod] + [DataRow(false, "Ægir Straße smør", "Ægir Straße smør")] + [DataRow(true, "Ægir Straße smør", "Aegir Strasse smor")] + [DataRow(true, "Łódź Œuvre Þing", "Lodz Oeuvre Thing")] + [DataRow(true, "Борис", "Boris")] + [DataRow(true, "深圳", "ShenZhen")] + [DataRow(true, "A\u0301rvi\u0301z", "Arviz")] + [DataRow(true, "👑", "-crown")] + public void Transliteration_IsOptionalAndUsesAnyAscii(bool enabled, string input, string expected) + { + using var option = new Swindler(enabled, + () => ContentNaming.Transliterate, value => ContentNaming.Transliterate = value); + Assert.AreEqual(expected, + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(null, input)); + } + + [DataTestMethod] + [DataRow("ä", "ae", "Fähre", "Faehre")] + [DataRow("ab", "xy", "abABab", "xyABxy")] + [DataRow("!", "", "Name!", "Name")] + [DataRow(";", ",", "a;b", "a-b")] + [DataRow(" ", "-", "Árvíztűrő tükörfúrógép", "Árvíztűrő-tükörfúrógép")] + [DataRow("😀", "smile", "😀 Name", "smile Name")] + public void CustomReplacements_AreLiteralCaseSensitiveAndRunBeforeValidation( + string source, string target, string input, string expected) + { + using var option = new Swindler(true, + () => ContentNaming.ApplyCustomReplacements, value => ContentNaming.ApplyCustomReplacements = value); + using var sources = new Swindler(new[] { source }, + () => ContentNaming.ReplacementSources, value => ContentNaming.ReplacementSources = value); + using var targets = new Swindler(new[] { target }, + () => ContentNaming.ReplacementTargets, value => ContentNaming.ReplacementTargets = value); + + Assert.AreEqual(expected, + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(null, input)); + } + + [DataTestMethod] + [DataRow(false, false, false, false, "ß Á")] + [DataRow(false, false, false, true, "ß á")] + [DataRow(false, true, false, false, "ß A")] + [DataRow(false, true, false, true, "ß a")] + [DataRow(false, false, true, false, "ss A")] + [DataRow(false, false, true, true, "ss a")] + [DataRow(false, true, true, false, "ss A")] + [DataRow(false, true, true, true, "ss a")] + [DataRow(true, false, false, false, "Ä Á")] + [DataRow(true, false, false, true, "ä á")] + [DataRow(true, true, false, false, "A A")] + [DataRow(true, true, false, true, "a a")] + [DataRow(true, false, true, false, "A A")] + [DataRow(true, false, true, true, "a a")] + [DataRow(true, true, true, false, "A A")] + [DataRow(true, true, true, true, "a a")] + public void FourOptions_AreIndependentAndCustomReplacementsRunFirst( + bool replace, bool removeDiacritics, bool transliterate, bool lowercase, string expected) + { + using var replacement = new Swindler(replace, + () => ContentNaming.ApplyCustomReplacements, value => ContentNaming.ApplyCustomReplacements = value); + using var diacritics = new Swindler(removeDiacritics, + () => ContentNaming.RemoveDiacritics, value => ContentNaming.RemoveDiacritics = value); + using var ascii = new Swindler(transliterate, + () => ContentNaming.Transliterate, value => ContentNaming.Transliterate = value); + using var casing = new Swindler(lowercase, + () => ContentNaming.Lowercase, value => ContentNaming.Lowercase = value); + using var sources = new Swindler(new[] { "ß" }, + () => ContentNaming.ReplacementSources, value => ContentNaming.ReplacementSources = value); + using var targets = new Swindler(new[] { "Ä" }, + () => ContentNaming.ReplacementTargets, value => ContentNaming.ReplacementTargets = value); + + Assert.AreEqual(expected, + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(null, "ß Á")); + } + + [TestMethod] + public void CustomReplacements_AreAppliedInConfiguredOrder() + { + using var option = new Swindler(true, + () => ContentNaming.ApplyCustomReplacements, value => ContentNaming.ApplyCustomReplacements = value); + using var sources = new Swindler(new[] { "a", "b" }, + () => ContentNaming.ReplacementSources, value => ContentNaming.ReplacementSources = value); + using var targets = new Swindler(new[] { "b", "c" }, + () => ContentNaming.ReplacementTargets, value => ContentNaming.ReplacementTargets = value); + + Assert.AreEqual("cc", + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(null, "ab")); + } + + [DataTestMethod] + [DataRow(new[] { "a" }, new string[0])] + [DataRow(new[] { "" }, new[] { "x" })] + [DataRow(new string[] { null }, new[] { "x" })] + [DataRow(new[] { "a" }, new string[] { null })] + public void CustomReplacements_RejectInvalidPairsOnlyWhenEnabled(string[] from, string[] to) + { + using var sources = new Swindler(from, + () => ContentNaming.ReplacementSources, value => ContentNaming.ReplacementSources = value); + using var targets = new Swindler(to, + () => ContentNaming.ReplacementTargets, value => ContentNaming.ReplacementTargets = value); + var provider = new CharReplacementContentNamingProvider(); + Assert.AreEqual("Name", provider.GenerateNameFromDisplayName(null, "Name")); + + using var option = new Swindler(true, + () => ContentNaming.ApplyCustomReplacements, value => ContentNaming.ApplyCustomReplacements = value); + Assert.ThrowsException(() => provider.GenerateNameFromDisplayName(null, "Name")); + } + + [TestMethod] + public void NewFeatures_PreserveOriginalExtensionAndEncodingProvider() + { + using var replacement = new Swindler(true, + () => ContentNaming.ApplyCustomReplacements, value => ContentNaming.ApplyCustomReplacements = value); + using var ascii = new Swindler(true, + () => ContentNaming.Transliterate, value => ContentNaming.Transliterate = value); + using var sources = new Swindler(new[] { "Æ" }, + () => ContentNaming.ReplacementSources, value => ContentNaming.ReplacementSources = value); + using var targets = new Swindler(new[] { "Œ" }, + () => ContentNaming.ReplacementTargets, value => ContentNaming.ReplacementTargets = value); + + Assert.AreEqual("Oegir.DÓC", + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName("old.DÓC", "Ægir.DÓC")); + Assert.AreEqual("Ægir.DÓC", + new Underscore5FContentNamingProvider().GenerateNameFromDisplayName(null, "Ægir.DÓC")); + } + } +} diff --git a/src/Tests/SenseNet.ContentRepository.Tests/ContentNamingNormalizationTests.cs b/src/Tests/SenseNet.ContentRepository.Tests/ContentNamingNormalizationTests.cs new file mode 100644 index 000000000..c413cfb80 --- /dev/null +++ b/src/Tests/SenseNet.ContentRepository.Tests/ContentNamingNormalizationTests.cs @@ -0,0 +1,132 @@ +using System.Globalization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SenseNet.Configuration; +using SenseNet.Testing; + +namespace SenseNet.ContentRepository.Tests +{ + [TestClass] + [DoNotParallelize] + public class ContentNamingNormalizationTests + { + [TestMethod] + public void Normalization_IsDisabledByDefault() + { + Assert.IsFalse(ContentNaming.RemoveDiacritics); + Assert.IsFalse(ContentNaming.Lowercase); + } + + [DataTestMethod] + [DataRow(false, "Árvíz tükör")] + [DataRow(true, "Arviz tukor")] + public void DiacriticRemoval_IsOptional(bool enabled, string expected) + { + using var diacritics = new Swindler(enabled, + () => ContentNaming.RemoveDiacritics, value => ContentNaming.RemoveDiacritics = value); + + Assert.AreEqual(expected, + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(null, "Árvíz tükör")); + } + + [DataTestMethod] + [DataRow(null, "Árvíztűrő tükörfúrógép", "Arvizturo tukorfurogep")] + [DataRow(null, "áéíóöőúüű ÁÉÍÓÖŐÚÜŰ", "aeiooouuu AEIOOOUUU")] + [DataRow(null, "A\u0301rvi\u0301ztu\u030Bro\u030B tu\u0308ko\u0308rfu\u0301ro\u0301ge\u0301p", "Arvizturo tukorfurogep")] + [DataRow(null, "Árvíz.txt", "Arviz.txt")] + [DataRow("régi.txt", "Új név", "Uj nev.txt")] + [DataRow("régi.txt", "Új név.txt", "Uj nev.txt")] + [DataRow("régi.txt", "Új név.doc", "Uj nev.doc.txt")] + [DataRow("régi.dóc", "Új név.dóc", "Uj nev.dóc")] + [DataRow(null, "Új név.dóc", "Uj nev.doc")] + [DataRow(null, "東京 café", "東京 cafe")] + [DataRow(null, "Straße smør", "Straße smør")] + [DataRow(null, "😀 Árvíz", "😀 Arviz")] + [DataRow(null, "A\U0001d185 B", "A B")] + [DataRow(null, " Árvíz ", " Arviz ")] + [DataRow(null, "\u0301\u0308", "")] + [DataRow(null, "", "")] + [DataRow("régi.txt", null, null)] + public void DiacriticRemoval_PreservesExistingNamingRules( + string originalName, string displayName, string expected) + { + using var diacritics = new Swindler(true, + () => ContentNaming.RemoveDiacritics, value => ContentNaming.RemoveDiacritics = value); + + Assert.AreEqual(expected, + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(originalName, displayName)); + } + + [TestMethod] + public void DiacriticRemoval_RunsBeforeInvalidCharacterReplacement() + { + using var diacritics = new Swindler(true, + () => ContentNaming.RemoveDiacritics, value => ContentNaming.RemoveDiacritics = value); + using var pattern = new Swindler("[^a-zA-Z0-9.\\-_]", + () => ContentNaming.InvalidNameCharsPattern, value => ContentNaming.InvalidNameCharsPattern = value); + + Assert.AreEqual("Arviz-tukor", + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(null, "Árvíz tükör")); + } + + [TestMethod] + public void Normalization_DoesNotChangeTheEncodingProvider() + { + using var diacritics = new Swindler(true, + () => ContentNaming.RemoveDiacritics, value => ContentNaming.RemoveDiacritics = value); + + using var lowercase = new Swindler(true, + () => ContentNaming.Lowercase, value => ContentNaming.Lowercase = value); + + Assert.AreEqual("Árvíz tükör", + new Underscore5FContentNamingProvider().GenerateNameFromDisplayName(null, "Árvíz tükör")); + } + + [DataTestMethod] + [DataRow(false, false, "Árvíz tükör")] + [DataRow(true, false, "Arviz tukor")] + [DataRow(false, true, "árvíz tükör")] + [DataRow(true, true, "arviz tukor")] + public void Normalization_OptionsAreIndependent(bool removeDiacritics, bool lowercase, string expected) + { + using var diacritics = new Swindler(removeDiacritics, + () => ContentNaming.RemoveDiacritics, value => ContentNaming.RemoveDiacritics = value); + using var casing = new Swindler(lowercase, + () => ContentNaming.Lowercase, value => ContentNaming.Lowercase = value); + + Assert.AreEqual(expected, + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(null, "Árvíz tükör")); + } + + [DataTestMethod] + [DataRow(null, "ÁRVÍZ TÜKÖR", "árvíz tükör")] + [DataRow(null, "NEW.TXT", "new.txt")] + [DataRow("old.TXT", "NEW.TXT", "new.TXT")] + [DataRow("old.TXT", "NEW", "new.TXT")] + [DataRow("old.TXT", "NEW.DOC", "new.doc.TXT")] + [DataRow(null, "東京 CAFÉ 😀", "東京 café 😀")] + [DataRow(null, "", "")] + [DataRow("old.TXT", null, null)] + public void Lowercase_PreservesExistingNamingRules(string originalName, string displayName, string expected) + { + using var casing = new Swindler(true, + () => ContentNaming.Lowercase, value => ContentNaming.Lowercase = value); + + Assert.AreEqual(expected, + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(originalName, displayName)); + } + + [TestMethod] + public void Lowercase_UsesInvariantCasingBeforeInvalidCharacterReplacement() + { + using var casing = new Swindler(true, + () => ContentNaming.Lowercase, value => ContentNaming.Lowercase = value); + using var culture = new Swindler(new CultureInfo("tr-TR"), + () => CultureInfo.CurrentCulture, value => CultureInfo.CurrentCulture = value); + using var pattern = new Swindler("[^a-z]", + () => ContentNaming.InvalidNameCharsPattern, value => ContentNaming.InvalidNameCharsPattern = value); + + Assert.AreEqual("file", + new CharReplacementContentNamingProvider().GenerateNameFromDisplayName(null, "FILE")); + } + } +} diff --git a/src/Tests/SenseNet.ODataTests/ODataContentNamingFeatureTests.cs b/src/Tests/SenseNet.ODataTests/ODataContentNamingFeatureTests.cs new file mode 100644 index 000000000..765e953eb --- /dev/null +++ b/src/Tests/SenseNet.ODataTests/ODataContentNamingFeatureTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Threading; +using Task = System.Threading.Tasks.Task; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SenseNet.Configuration; +using SenseNet.ContentRepository; +using SenseNet.ContentRepository.Storage; +using SenseNet.Testing; +using Settings = SenseNet.ContentRepository.Settings; + +namespace SenseNet.ODataTests +{ + [TestClass] + [DoNotParallelize] + public class ODataContentNamingFeatureTests : ODataTestBase + { + [DataTestMethod] + [DataRow(true, false, false, "Észak Straße Árvíz")] + [DataRow(false, true, false, "Aegir Strasse Arviz")] + [DataRow(true, true, false, "Eszak Strasse Arviz")] + [DataRow(true, true, true, "eszak strasse arviz")] + public async Task OD_POST_NewFeaturesPreserveDisplayName(bool replace, bool transliterate, bool lowercase, + string expectedName) + { + await ODataTestAsync(async () => + { + using var replacement = ConfigureOption(nameof(ContentNaming.ApplyCustomReplacements), replace); + using var ascii = ConfigureOption(nameof(ContentNaming.Transliterate), transliterate); + using var casing = ConfigureOption(nameof(ContentNaming.Lowercase), lowercase); + using var sources = new Swindler(new[] { "Ægir" }, + () => ContentNaming.ReplacementSources, value => ContentNaming.ReplacementSources = value); + using var targets = new Swindler(new[] { "Észak" }, + () => ContentNaming.ReplacementTargets, value => ContentNaming.ReplacementTargets = value); + + var saved = await CreateFolderAsync("Ægir Straße Árvíz", expectedName).ConfigureAwait(false); + const string updatedDisplayName = "Új cím — őű"; + var response = await ODataPatchAsync("/OData.svc" + saved.Path, "", + "models=[" + new JObject { ["DisplayName"] = updatedDisplayName }.ToString(Formatting.None) + "]") + .ConfigureAwait(false); + AssertNoError(response); + saved = Content.Load(saved.Id); + Assert.AreEqual(expectedName, saved.Name); + Assert.AreEqual(updatedDisplayName, saved.DisplayName); + }).ConfigureAwait(false); + } + + [DataTestMethod] + [DataRow(nameof(ContentNaming.RemoveDiacritics), "Straße ARVIZ")] + [DataRow(nameof(ContentNaming.Lowercase), "straße árvíz")] + [DataRow(nameof(ContentNaming.Transliterate), "Strasse ARVIZ")] + [DataRow(nameof(ContentNaming.ApplyCustomReplacements), "Új ÁRVÍZ")] + public async Task OD_Settings_CanVetoEachConfiguredOptionWithoutRestart(string option, string enabledName) + { + await ODataTestAsync(async () => + { + Assert.IsTrue(Repository.Started(), "The Settings override must be tested on a running repository."); + using var feature = ConfigureOption(option, true); + using var sources = new Swindler(new[] { "Straße" }, + () => ContentNaming.ReplacementSources, value => ContentNaming.ReplacementSources = value); + using var targets = new Swindler(new[] { "Új" }, + () => ContentNaming.ReplacementTargets, value => ContentNaming.ReplacementTargets = value); + const string original = "Straße ÁRVÍZ"; + + // No settings file: use the configured feature. + var before = await CreateFolderAsync(original, enabledName).ConfigureAwait(false); + var settings = new Settings(Node.LoadNode(Repository.SettingsFolderPath)) + { + Name = "ContentNaming.settings" + }; + settings.Binary.SetStream(RepositoryTools.GetStreamFromString("{}")); + await settings.SaveAsync(CancellationToken.None).ConfigureAwait(false); + + // No key: still use the configured feature. + await CreateFolderAsync(original, enabledName).ConfigureAwait(false); + + // Persist changes through the existing OData Settings API. Do not clear caches or restart. + await WriteGlobalSettingsAsync(new JObject { [option] = false }).ConfigureAwait(false); + await CreateFolderAsync(original, original).ConfigureAwait(false); + Assert.AreEqual(enabledName, Content.Load(before.Id).Name, "Existing content must retain its name."); + + await WriteGlobalSettingsAsync(new JObject { [option] = true }).ConfigureAwait(false); + await CreateFolderAsync(original, enabledName).ConfigureAwait(false); + + await WriteGlobalSettingsAsync(new JObject()).ConfigureAwait(false); + await CreateFolderAsync(original, enabledName).ConfigureAwait(false); + + // Settings true cannot enable a feature that the host configuration disables. + await WriteGlobalSettingsAsync(new JObject { [option] = true }).ConfigureAwait(false); + using var disabled = ConfigureOption(option, false); + await CreateFolderAsync(original, original).ConfigureAwait(false); + }).ConfigureAwait(false); + } + + private async System.Threading.Tasks.Task CreateFolderAsync(string displayName, string expectedName) + { + var root = CreateTestRoot(); + var model = new JObject { ["__ContentType"] = "Folder", ["DisplayName"] = displayName }; + var response = await ODataPostAsync("/OData.svc" + root.Path, "", + "models=[" + model.ToString(Formatting.None) + "]").ConfigureAwait(false); + AssertNoError(response); + var entity = GetEntity(response); + var saved = Content.Load(entity.Id); + Assert.AreEqual(expectedName, entity.Name); + Assert.AreEqual(expectedName, saved.Name); + Assert.AreEqual(displayName, saved.DisplayName); + return saved; + } + + private async Task WriteGlobalSettingsAsync(JObject values) + { + var model = new JObject { ["name"] = "ContentNaming", ["settingsData"] = values }; + var response = await ODataPostAsync("/OData.svc/Root('System')/WriteSettings", null, + "models=[" + model.ToString(Formatting.None) + "]").ConfigureAwait(false); + AssertNoError(response); + Assert.AreEqual(204, response.StatusCode); + } + + private static Swindler ConfigureOption(string name, bool enabled) + { + return name switch + { + nameof(ContentNaming.RemoveDiacritics) => new Swindler(enabled, + () => ContentNaming.RemoveDiacritics, value => ContentNaming.RemoveDiacritics = value), + nameof(ContentNaming.Lowercase) => new Swindler(enabled, + () => ContentNaming.Lowercase, value => ContentNaming.Lowercase = value), + nameof(ContentNaming.Transliterate) => new Swindler(enabled, + () => ContentNaming.Transliterate, value => ContentNaming.Transliterate = value), + nameof(ContentNaming.ApplyCustomReplacements) => new Swindler(enabled, + () => ContentNaming.ApplyCustomReplacements, value => ContentNaming.ApplyCustomReplacements = value), + _ => throw new ArgumentOutOfRangeException(nameof(name)) + }; + } + } +} diff --git a/src/Tests/SenseNet.ODataTests/ODataContentNamingTests.cs b/src/Tests/SenseNet.ODataTests/ODataContentNamingTests.cs new file mode 100644 index 000000000..bb7fb6f69 --- /dev/null +++ b/src/Tests/SenseNet.ODataTests/ODataContentNamingTests.cs @@ -0,0 +1,73 @@ +using Task = System.Threading.Tasks.Task; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SenseNet.Configuration; +using SenseNet.ContentRepository; +using SenseNet.Testing; + +namespace SenseNet.ODataTests +{ + [TestClass] + [DoNotParallelize] + public class ODataContentNamingTests : ODataTestBase + { + [DataTestMethod] + [DataRow(false, false, false, null, "Árvíztűrő tükörfúrógép")] + [DataRow(true, false, false, null, "Arvizturo tukorfurogep")] + [DataRow(false, true, false, null, "árvíztűrő tükörfúrógép")] + [DataRow(true, true, false, null, "arvizturo tukorfurogep")] + [DataRow(false, false, false, "Kézi név", "Kézi név")] + [DataRow(true, false, false, "Kézi név", "Kezi nev")] + [DataRow(false, true, false, "Kézi név", "kézi név")] + [DataRow(true, true, false, "Kézi név", "kezi nev")] + [DataRow(true, true, true, null, "arvizturo-tukorfurogep")] + [DataRow(true, true, true, "Kézi név", "kezi-nev")] + public async Task OD_POST_NormalizationOnlyChangesName( + bool removeDiacritics, bool lowercase, bool replaceWhitespace, string explicitName, string expectedName) + { + await ODataTestAsync(async () => + { + using var diacritics = new Swindler(removeDiacritics, + () => ContentNaming.RemoveDiacritics, value => ContentNaming.RemoveDiacritics = value); + + using var casing = new Swindler(lowercase, + () => ContentNaming.Lowercase, value => ContentNaming.Lowercase = value); + using var pattern = new Swindler(replaceWhitespace + ? ContentNaming.InvalidNameCharsPattern.Insert(ContentNaming.InvalidNameCharsPattern.Length - 1, @"\s") + : ContentNaming.InvalidNameCharsPattern, + () => ContentNaming.InvalidNameCharsPattern, value => ContentNaming.InvalidNameCharsPattern = value); + + const string displayName = "Árvíztűrő tükörfúrógép"; + var root = CreateTestRoot("ODataNamingTestRoot"); + var model = new JObject + { + ["__ContentType"] = "Folder", + ["DisplayName"] = displayName + }; + if (explicitName != null) + model["Name"] = explicitName; + + var response = await ODataPostAsync("/OData.svc" + root.Path, "", + "models=[" + model.ToString(Formatting.None) + "]").ConfigureAwait(false); + + AssertNoError(response); + var entity = GetEntity(response); + var saved = Content.Load(entity.Id); + Assert.AreEqual(expectedName, entity.Name); + Assert.AreEqual(expectedName, saved.Name); + Assert.AreEqual(displayName, saved.DisplayName); + + const string updatedDisplayName = "Új cím — őű"; + var update = new JObject { ["DisplayName"] = updatedDisplayName }; + response = await ODataPatchAsync("/OData.svc" + saved.Path, "", + "models=[" + update.ToString(Formatting.None) + "]").ConfigureAwait(false); + + AssertNoError(response); + saved = Content.Load(entity.Id); + Assert.AreEqual(expectedName, saved.Name); + Assert.AreEqual(updatedDisplayName, saved.DisplayName); + }).ConfigureAwait(false); + } + } +} diff --git a/src/WebApps/SnWebApplication.Api.InMem.TokenAuth/appsettings.json b/src/WebApps/SnWebApplication.Api.InMem.TokenAuth/appsettings.json index 31520ce3d..8301a58b6 100644 --- a/src/WebApps/SnWebApplication.Api.InMem.TokenAuth/appsettings.json +++ b/src/WebApps/SnWebApplication.Api.InMem.TokenAuth/appsettings.json @@ -18,6 +18,26 @@ }, "AllowedHosts": "*", "sensenet": { + "contentNaming": { + "RemoveDiacritics": false, + "Lowercase": false, + "Transliterate": false, + "ApplyCustomReplacements": false, + "ReplacementSources": [ + "ä", "Ä", "ö", "Ö", "ü", "Ü", "ß", "ẞ", + "æ", "Æ", "œ", "Œ", "ø", "Ø", "ł", "Ł", + "đ", "Đ", "ð", "Ð", "þ", "Þ", "ħ", "Ħ", + "ı", "ij", "IJ", "ŋ", "Ŋ", "ŧ", "Ŧ", "ſ", "ŀ", "Ŀ", + "&", " " + ], + "ReplacementTargets": [ + "ae", "AE", "oe", "OE", "ue", "UE", "ss", "SS", + "ae", "AE", "oe", "OE", "o", "O", "l", "L", + "d", "D", "d", "D", "th", "TH", "h", "H", + "i", "ij", "IJ", "n", "N", "t", "T", "s", "l", "L", + "and", "-" + ] + }, "authentication": { "authServerType": "IdentityServer", "authority": "https://localhost:44311", diff --git a/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/appsettings.json b/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/appsettings.json index 6adecb8d9..bc181f062 100644 --- a/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/appsettings.json +++ b/src/WebApps/SnWebApplication.Api.Sql.LocalAuth/appsettings.json @@ -21,6 +21,26 @@ }, "AllowedHosts": "*", "sensenet": { + "contentNaming": { + "RemoveDiacritics": false, + "Lowercase": false, + "Transliterate": false, + "ApplyCustomReplacements": false, + "ReplacementSources": [ + "ä", "Ä", "ö", "Ö", "ü", "Ü", "ß", "ẞ", + "æ", "Æ", "œ", "Œ", "ø", "Ø", "ł", "Ł", + "đ", "Đ", "ð", "Ð", "þ", "Þ", "ħ", "Ħ", + "ı", "ij", "IJ", "ŋ", "Ŋ", "ŧ", "Ŧ", "ſ", "ŀ", "Ŀ", + "&", " " + ], + "ReplacementTargets": [ + "ae", "AE", "oe", "OE", "ue", "UE", "ss", "SS", + "ae", "AE", "oe", "OE", "o", "O", "l", "L", + "d", "D", "d", "D", "th", "TH", "h", "H", + "i", "ij", "IJ", "n", "N", "t", "T", "s", "l", "L", + "and", "-" + ] + }, "authentication": { "authServerType": "IdentityServer", "authority": "https://localhost:44311", diff --git a/src/WebApps/SnWebApplication.Api.Sql.SearchService.TokenAuth.Preview/appsettings.json b/src/WebApps/SnWebApplication.Api.Sql.SearchService.TokenAuth.Preview/appsettings.json index 62d9318de..ac3c85ac6 100644 --- a/src/WebApps/SnWebApplication.Api.Sql.SearchService.TokenAuth.Preview/appsettings.json +++ b/src/WebApps/SnWebApplication.Api.Sql.SearchService.TokenAuth.Preview/appsettings.json @@ -21,6 +21,26 @@ }, "AllowedHosts": "*", "sensenet": { + "contentNaming": { + "RemoveDiacritics": false, + "Lowercase": false, + "Transliterate": false, + "ApplyCustomReplacements": false, + "ReplacementSources": [ + "ä", "Ä", "ö", "Ö", "ü", "Ü", "ß", "ẞ", + "æ", "Æ", "œ", "Œ", "ø", "Ø", "ł", "Ł", + "đ", "Đ", "ð", "Ð", "þ", "Þ", "ħ", "Ħ", + "ı", "ij", "IJ", "ŋ", "Ŋ", "ŧ", "Ŧ", "ſ", "ŀ", "Ŀ", + "&", " " + ], + "ReplacementTargets": [ + "ae", "AE", "oe", "OE", "ue", "UE", "ss", "SS", + "ae", "AE", "oe", "OE", "o", "O", "l", "L", + "d", "D", "d", "D", "th", "TH", "h", "H", + "i", "ij", "IJ", "n", "N", "t", "T", "s", "l", "L", + "and", "-" + ] + }, "authentication": { "authServerType": "IdentityServer", "authority": "https://localhost:44311", diff --git a/src/WebApps/SnWebApplication.Api.Sql.SearchService.TokenAuth/appsettings.json b/src/WebApps/SnWebApplication.Api.Sql.SearchService.TokenAuth/appsettings.json index fe3930bec..8410c5935 100644 --- a/src/WebApps/SnWebApplication.Api.Sql.SearchService.TokenAuth/appsettings.json +++ b/src/WebApps/SnWebApplication.Api.Sql.SearchService.TokenAuth/appsettings.json @@ -21,6 +21,26 @@ }, "AllowedHosts": "*", "sensenet": { + "contentNaming": { + "RemoveDiacritics": false, + "Lowercase": false, + "Transliterate": false, + "ApplyCustomReplacements": false, + "ReplacementSources": [ + "ä", "Ä", "ö", "Ö", "ü", "Ü", "ß", "ẞ", + "æ", "Æ", "œ", "Œ", "ø", "Ø", "ł", "Ł", + "đ", "Đ", "ð", "Ð", "þ", "Þ", "ħ", "Ħ", + "ı", "ij", "IJ", "ŋ", "Ŋ", "ŧ", "Ŧ", "ſ", "ŀ", "Ŀ", + "&", " " + ], + "ReplacementTargets": [ + "ae", "AE", "oe", "OE", "ue", "UE", "ss", "SS", + "ae", "AE", "oe", "OE", "o", "O", "l", "L", + "d", "D", "d", "D", "th", "TH", "h", "H", + "i", "ij", "IJ", "n", "N", "t", "T", "s", "l", "L", + "and", "-" + ] + }, "authentication": { "authServerType": "IdentityServer", "authority": "https://localhost:44311", diff --git a/src/WebApps/SnWebApplication.Api.Sql.TokenAuth.NLB/appsettings.json b/src/WebApps/SnWebApplication.Api.Sql.TokenAuth.NLB/appsettings.json index 08cda6c22..efa616bff 100644 --- a/src/WebApps/SnWebApplication.Api.Sql.TokenAuth.NLB/appsettings.json +++ b/src/WebApps/SnWebApplication.Api.Sql.TokenAuth.NLB/appsettings.json @@ -21,6 +21,26 @@ }, "AllowedHosts": "*", "sensenet": { + "contentNaming": { + "RemoveDiacritics": false, + "Lowercase": false, + "Transliterate": false, + "ApplyCustomReplacements": false, + "ReplacementSources": [ + "ä", "Ä", "ö", "Ö", "ü", "Ü", "ß", "ẞ", + "æ", "Æ", "œ", "Œ", "ø", "Ø", "ł", "Ł", + "đ", "Đ", "ð", "Ð", "þ", "Þ", "ħ", "Ħ", + "ı", "ij", "IJ", "ŋ", "Ŋ", "ŧ", "Ŧ", "ſ", "ŀ", "Ŀ", + "&", " " + ], + "ReplacementTargets": [ + "ae", "AE", "oe", "OE", "ue", "UE", "ss", "SS", + "ae", "AE", "oe", "OE", "o", "O", "l", "L", + "d", "D", "d", "D", "th", "TH", "h", "H", + "i", "ij", "IJ", "n", "N", "t", "T", "s", "l", "L", + "and", "-" + ] + }, "authentication": { "authServerType": "IdentityServer", "authority": "https://localhost:44311", diff --git a/src/WebApps/SnWebApplication.Api.Sql.TokenAuth.Preview/appsettings.json b/src/WebApps/SnWebApplication.Api.Sql.TokenAuth.Preview/appsettings.json index 467517aa8..7ad392f50 100644 --- a/src/WebApps/SnWebApplication.Api.Sql.TokenAuth.Preview/appsettings.json +++ b/src/WebApps/SnWebApplication.Api.Sql.TokenAuth.Preview/appsettings.json @@ -21,6 +21,26 @@ }, "AllowedHosts": "*", "sensenet": { + "contentNaming": { + "RemoveDiacritics": false, + "Lowercase": false, + "Transliterate": false, + "ApplyCustomReplacements": false, + "ReplacementSources": [ + "ä", "Ä", "ö", "Ö", "ü", "Ü", "ß", "ẞ", + "æ", "Æ", "œ", "Œ", "ø", "Ø", "ł", "Ł", + "đ", "Đ", "ð", "Ð", "þ", "Þ", "ħ", "Ħ", + "ı", "ij", "IJ", "ŋ", "Ŋ", "ŧ", "Ŧ", "ſ", "ŀ", "Ŀ", + "&", " " + ], + "ReplacementTargets": [ + "ae", "AE", "oe", "OE", "ue", "UE", "ss", "SS", + "ae", "AE", "oe", "OE", "o", "O", "l", "L", + "d", "D", "d", "D", "th", "TH", "h", "H", + "i", "ij", "IJ", "n", "N", "t", "T", "s", "l", "L", + "and", "-" + ] + }, "authentication": { "authServerType": "IdentityServer", "authority": "https://localhost:44311", diff --git a/src/WebApps/SnWebApplication.Api.Sql.TokenAuth/appsettings.json b/src/WebApps/SnWebApplication.Api.Sql.TokenAuth/appsettings.json index b78211dd3..c58a3176a 100644 --- a/src/WebApps/SnWebApplication.Api.Sql.TokenAuth/appsettings.json +++ b/src/WebApps/SnWebApplication.Api.Sql.TokenAuth/appsettings.json @@ -21,6 +21,26 @@ }, "AllowedHosts": "*", "sensenet": { + "contentNaming": { + "RemoveDiacritics": false, + "Lowercase": false, + "Transliterate": false, + "ApplyCustomReplacements": false, + "ReplacementSources": [ + "ä", "Ä", "ö", "Ö", "ü", "Ü", "ß", "ẞ", + "æ", "Æ", "œ", "Œ", "ø", "Ø", "ł", "Ł", + "đ", "Đ", "ð", "Ð", "þ", "Þ", "ħ", "Ħ", + "ı", "ij", "IJ", "ŋ", "Ŋ", "ŧ", "Ŧ", "ſ", "ŀ", "Ŀ", + "&", " " + ], + "ReplacementTargets": [ + "ae", "AE", "oe", "OE", "ue", "UE", "ss", "SS", + "ae", "AE", "oe", "OE", "o", "O", "l", "L", + "d", "D", "d", "D", "th", "TH", "h", "H", + "i", "ij", "IJ", "n", "N", "t", "T", "s", "l", "L", + "and", "-" + ] + }, "authentication": { "authServerType": "IdentityServer", "authority": "https://localhost:44311",