diff --git a/Source/FileManager/NamingTemplate/CommonFormatters.cs b/Source/FileManager/NamingTemplate/CommonFormatters.cs index 66b13634..1469ba93 100644 --- a/Source/FileManager/NamingTemplate/CommonFormatters.cs +++ b/Source/FileManager/NamingTemplate/CommonFormatters.cs @@ -146,10 +146,13 @@ public static partial class CommonFormatters public static string FormattableFormatter(ITemplateTag _, IFormattable? value, string? formatString, CultureInfo? culture) => value?.ToString(formatString, culture) ?? ""; - public static string IntegerFormatter(ITemplateTag templateTag, int value, string? formatString, CultureInfo? culture) - => FloatFormatter(templateTag, value, formatString, culture); + public static string IntegerFormatter(ITemplateTag _, int value, string? formatString, CultureInfo? culture) + => _FloatFormatter(value, formatString, culture); public static string FloatFormatter(ITemplateTag _, float value, string? formatString, CultureInfo? culture) + => _FloatFormatter(value, formatString, culture); + + public static string _FloatFormatter(float value, string? formatString, CultureInfo? culture) { culture ??= CultureInfo.CurrentCulture; if (!int.TryParse(formatString, out var numDigits) || numDigits <= 0) return value.ToString(formatString, culture); diff --git a/Source/FileManager/NamingTemplate/CompareCondition.cs b/Source/FileManager/NamingTemplate/CompareCondition.cs new file mode 100644 index 00000000..306b1d47 --- /dev/null +++ b/Source/FileManager/NamingTemplate/CompareCondition.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace FileManager.NamingTemplate; + +public static partial class CompareCondition +{ + private static readonly ConcurrentDictionary RegexCache = new(); + + private static readonly TimeSpan RegexpCheckTimeout = TimeSpan.FromMilliseconds(100); + + public static (object, ConditionEvaluator) GetPredicateAndValue(string exactName, string? checkString) + { + if (checkString is null) + return (string.Empty, (v1, _, _) => v1 switch + { + null => false, + IEnumerable e => e.Any(), + _ => !string.IsNullOrWhiteSpace(v1.ToString()) + }); + + var match = GetMatch(exactName, checkString); + var valStr = match.UnescapeValue("val"); + var (evaluator, opGroup) = GetPredicate(exactName, match); + + return (opGroup.Name switch + { + "num_op" => int.Parse(valStr), // at this stage should have matched digits in CheckRegex + "list_op" => new[] { valStr }, + _ => valStr + }, evaluator); + } + + public static ConditionEvaluator GetPredicate(string exactName, string? checkString) + { + return GetPredicate(exactName, GetMatch(exactName, checkString)).Item1; + } + + private static Match GetMatch(string exactName, string? checkString) + { + return CheckRegex().TryMatch(checkString, out var match) + ? match + : throw new ArgumentException($"Invalid check or operator format in conditional tag '{exactName}'. Check string: '{checkString}'"); + } + + private static (ConditionEvaluator, Group) GetPredicate(string exactName, Match match) + { + var group = match.Groups["num_op"]; + if (group.Success) + { + return (GetPredicateForNumOp(exactName, group.ValueSpan), group); + } + + group = match.Groups["list_op"]; + if (group.Success) + { + return (GetPredicateForListOp(exactName, group.ValueSpan), group); + } + + group = match.Groups["op"]; + return (GetPredicateForStringOp(exactName, group.ValueSpan), group); + } + + private static ConditionEvaluator GetPredicateForNumOp(string _, ReadOnlySpan opString) + { + Func checkInt = opString switch + { + "#=" => (v1, v2, _) => v1 == v2, + "#!=" or "≠" or "≠" => (v1, v2, _) => v1 != v2, + "#>=" or ">=" or "≥" => (v1, v2, _) => v1 >= v2, + "#>" or ">" => (v1, v2, _) => v1 > v2, + "#<=" or "<=" or "≤" => (v1, v2, _) => v1 <= v2, + "#<" or "<" => (v1, v2, _) => v1 < v2, + _ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values + }; + return (v1, v2, culture) => ToIntObject(v1) is { } i1 && ToIntObject(v2) is { } i2 && checkInt(i1, i2, culture); + } + + private static int? ToIntObject(object? value) + { + return value switch + { + null => null, + IEnumerable e => e.Count(), + TimeSpan ts => (int)ts.TotalMinutes, + DateTime dt => (int)dt.ToOADate(), + string s => s.Length, + int i => i, + _ => null // language and such shall never match + }; + } + + private static ConditionEvaluator GetPredicateForListOp(string _, ReadOnlySpan opString) + { + var checklist = opString switch + { + "∋" or ">>" or ":contains:" => Swap>(IsSubset), + "⊇" or ">=>" or ":superset:" => Swap>(IsSubset), + "⊃" or ">->" or ":proper_superset:" => Swap>(IsProperSubset), + "∌" or "!>>" or "∌" or ":not_contains:" => Invert(Swap>(IsSubset)), + "∈" or "<<" or ":in:" => IsSubset, + "⊆" or "<=<" or ":subset:" => IsSubset, + "⊂" or "<-<" or ":proper_subset:" => IsProperSubset, + "∉" or "!<<" or "∉" or ":not_in:" => Invert>(IsSubset), + "⋂" or "&&" or ":overlaps:" => Overlaps, + "⋂̸" or "&&!" or "⋂!" or ":disjoint:" => Invert>(Overlaps), + "≡" or "==" or ":equals:" => (e1, e2, culture) => + { + var cmp = GetStringComparer(culture); + return e1.OrderBy(e => e, cmp).SequenceEqual(e2.OrderBy(e => e, cmp), cmp); + }, + _ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values + }; + return (v1, v2, culture) => v1 is not null && v2 is not null && checklist(ToEnumerable(v1), ToEnumerable(v2), culture); + } + + private static IEnumerable ToEnumerable(object value) + { + return value switch + { + IEnumerable e => e, + IEnumerable e => e.Select(o => o.ToString() ?? ""), + string s => [s], + _ => [value.ToString() ?? ""] + }; + } + + private static ConditionEvaluator GetPredicateForStringOp(string exactName, ReadOnlySpan opString) + { + var checkItem = opString switch + { + "=" or "" => GetStringEqCheck(), + "!=" or "!" => Invert(GetStringEqCheck()), + "=~" or "~" => GetRegExpCheck(exactName), + "!~" => Invert(GetRegExpCheck(exactName)), + _ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values + }; + return (v1, v2, culture) => (v1, v2) switch + { + (null, _) => false, + (_, null) => false, + (IEnumerable e1, _) => e1.Any(l => checkItem(l, v2, culture)), + (_, IEnumerable e2) => e2.Any(r => checkItem(v1, r, culture)), + _ => checkItem(v1, v2, culture) + }; + } + + private static bool Overlaps(IEnumerable e1, IEnumerable e2, CultureInfo? culture) + { + var comparer = GetStringComparer(culture); + return e1.Any(l => e2.Contains(l, comparer)); + } + + private static bool IsSubset(IEnumerable e1, IEnumerable e2, CultureInfo? culture) + { + var comparer = GetStringComparer(culture); + return e1.All(l => e2.Contains(l, comparer)); + } + + private static bool IsProperSubset(IEnumerable e1, IEnumerable e2, CultureInfo? culture) + { + var comparer = GetStringComparer(culture); + // ReSharper disable PossibleMultipleEnumeration + return e1.All(l => e2.Contains(l, comparer)) && e2.Any(r => !e1.Contains(r, comparer)); + // ReSharper restore PossibleMultipleEnumeration + } + + private static Func Invert(Func condition) => (v1, v2, culture) => !condition(v1, v2, culture); + private static Func Swap(Func condition) => (v1, v2, culture) => condition(v2, v1, culture); + + private static Func GetStringEqCheck() + { + return (v1, v2, culture) => GetStringComparer(culture).Equals(ValueToString(v1, culture), ValueToString(v2, culture)); + } + + private static string? ValueToString(object value, CultureInfo? culture) + { + return value switch + { + TimeSpan ts => ts.TotalMinutes.ToString("0", culture), + IFormattable f => f.ToString(null, culture), + _ => value.ToString() + }; + } + + private static StringComparer GetStringComparer(CultureInfo? culture) + { + return StringComparer.Create(culture ?? CultureInfo.CurrentCulture, ignoreCase: true); + } + + /// + /// Build a regular expression check. Uses culture-invariant matching for thread-safety and consistency. + /// Applies a timeout to prevent regex patterns from causing excessive backtracking and blocking. + /// Throws InvalidOperationException if the regex pattern is invalid or evaluation times out. + /// + /// The full tag string for context in error messages + /// check function to validate an object + /// Thrown when regex parsing fails or when regex matching times out, indicating faulty user input + private static Func GetRegExpCheck(string exactName) + { + return (v1, v2, _) => + { + var pattern = v2.ToString() ?? ""; + var regex = RegexCache.GetOrAdd(pattern, p => + { + try + { + // Compile regex with timeout to prevent catastrophic backtracking + return new Regex(p, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, RegexpCheckTimeout); + } + catch (ArgumentException ex) + { + // If regex compilation fails, throw as faulty user input + var errorMessage = BuildErrorMessage(exactName, p, "Invalid regular expression pattern. Correct the pattern and escaping or remove that condition"); + throw new InvalidOperationException(errorMessage, ex); + } + }); + try + { + // CultureInfo parameter is intentionally ignored (discarded with _). + // RegexOptions.CultureInvariant ensures culture-independent matching for predictable behavior. + // This is preferred for template conditions because: + // 1. Thread-safety: Regex operations are isolated and don't depend on thread-local culture + // 2. Consistency: Template matches produce identical results regardless of system locale + // 3. Predictability: Rules don't unexpectedly change based on user's OS settings + // + // Culture-sensitive matching would be problematic in cases like: + // - Turkish locale: 'I' has different case folding (I ↔ ı vs. I ↔ i). Pattern "[i-z]" might match Turkish 'ı'. + // - German locale: ß might be treated as equivalent to 'ss' during case-insensitive matching. + // - Lithuanian locale: 'i' after 'ž' has an accent that affects sorting/matching. + // + // For naming templates, culture-invariant is the safer default. + return regex.IsMatch(v1.ToString() ?? ""); + } + catch (RegexMatchTimeoutException ex) + { + // Throw if regex evaluation times out, indicating faulty user input (e.g., catastrophic backtracking) + var errorMessage = BuildErrorMessage(exactName, pattern, "Regular expression pattern evaluation timed out. Use a simpler pattern or remove that condition"); + throw new InvalidOperationException(errorMessage, ex); + } + }; + } + + private static string BuildErrorMessage(string exactName, string pattern, string errorType) + { + const int maxMessageLen = 200; + + // Build a full message with the pattern + var fullMsg = $"{errorType}: {exactName} -> Pattern: {pattern}"; + + // Return a full message if it's within the character limit + if (fullMsg.Length <= maxMessageLen) return fullMsg; + + // Keep the error type and as much pattern as possible + var maxPatternLen = maxMessageLen - errorType.Length - 23; // Account for ". Pattern starts with: " + var trimmedPattern = pattern.Length > maxPatternLen ? pattern[..(maxPatternLen - 3)] + "..." : pattern; + return $"{errorType}. Pattern starts with: {trimmedPattern}"; + } + + [GeneratedRegex(""" + (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # + ^(?>(?(? # anchor at start of line. Capture operator in , and with every char escapable + ≡ | == | :equals: # - list operators: ≡ for checking if two lists contain the same items regardless of order + | ∌ | !>> | ∌ | :not_contains: # - list operators: ∌ for checking if the first list does not contain any item of the second list + | ∋ | >> | :contains: # - list operators: ∋ for checking if the first list contains all items of the second list + | ∉ | !<< | ∉ | :not_in: # - list operators: ∉ for checking if the first list is not contained in the second list + | ∈ | << | :in: # - list operators: ∈ for checking if the first list is contained in the second list + | ⋂̸ | &&! | ⋂! | :disjoint: # - list operators: ⋂̸ for checking if the two lists are disjoint + | ⋂ | && | :overlaps: # - list operators: ⋂ for checking if the two lists overlap in at least one item + | ⊆ | <=< | :subset: # - list operators: ⊆ for checking if the first list is a subset of the second list (may be equal) + | ⊇ | >=> | :superset: # - list operators: ⊇ for checking if the first list is a superset of the second list (may be equal) + | ⊂ | <-< | :proper_subset: # - list operators: ⊂ for checking if the first list is a proper subset of the second list (not equal) + | ⊃ | >-> | :proper_superset: # - list operators: ⊃ for checking if the first list is a proper superset of the second list (not equal) + ) | (? + \#!?= | ≠ | ≠ # - numerical operators: #= #!= ≠ + | \#[<>]=? # - numerical operators: #<= #>= #< #> + | [<>]=? | ≤ | ≥ # - numerical operators: <= >= < > ≤ ≥ + ) | [=!]?~ | !=? | =? # - string comparison operators including ~ for regexp, = and !=. No operator is like = + )) \s*? # ignore space between operator and value + (?(?(num_op) # capture value in + (?:\d)+ # - numerical operators have to be followed by a number + | (?:\\.|[^\\])+ ) # - string for comparison. May be empty. Capturing also all whitespace up to the end as this must have been escaped. + )?$ # match to the end + """)] + private static partial Regex CheckRegex(); +} \ No newline at end of file diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index b184e6ed..d4731613 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -76,8 +76,6 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) private partial class ConditionalTag : TagBase, IClosingPropertyTag { - private static readonly TimeSpan RegexpCheckTimeout = TimeSpan.FromMilliseconds(100); - public override Regex NameMatcher { get; } public Regex NameCloseMatcher { get; } @@ -140,7 +138,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) CreateConditionExpression = (exactName, property, checkString, _) => { - var (value, conditionEvaluator) = GetPredicateAndValue(exactName, checkString); + var (value, conditionEvaluator) = CompareCondition.GetPredicateAndValue(exactName, checkString); return ConditionEvaluatorCall(conditionEvaluator, ValueProviderCall(templateTag, parameter, valueProvider, property), BuildArgument(value, conditionEvaluator.Method.GetParameters()[1].ParameterType)); @@ -176,7 +174,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); CreateConditionExpression = (exactName, property1, checkString, property2) - => ConditionEvaluatorCall(GetPredicate(exactName, checkString), + => ConditionEvaluatorCall(CompareCondition.GetPredicate(exactName, checkString), ValueProviderCall(templateTag, parameter, valueProvider1, property1), ValueProviderCall(templateTag, parameter, valueProvider2, property2)); } @@ -208,258 +206,6 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) return constant.Type == targetType ? constant : Expression.Convert(constant, targetType); } - private static (object, ConditionEvaluator) GetPredicateAndValue(string exactName, string? checkString) - { - if (checkString is null) - return (string.Empty, (v1, _, _) => v1 switch - { - null => false, - IEnumerable e => e.Any(), - _ => !string.IsNullOrWhiteSpace(v1.ToString()) - }); - - var match = GetMatch(exactName, checkString); - var valStr = match.UnescapeValue("val"); - var (evaluator, opGroup) = GetPredicate(exactName, match); - - return (opGroup.Name switch - { - "num_op" => int.Parse(valStr!), // at this stage should have matched digits in CheckRegex - "list_op" => new[] { valStr ?? string.Empty }, - _ => valStr ?? string.Empty - }, evaluator); - } - - private static ConditionEvaluator GetPredicate(string exactName, string? checkString) - { - return GetPredicate(exactName, GetMatch(exactName, checkString)).Item1; - } - - private static Match GetMatch(string exactName, string? checkString) - { - return CheckRegex().TryMatch(checkString, out var match) - ? match - : throw new ArgumentException($"Invalid check or operator format in conditional tag '{exactName}'. Check string: '{checkString}'"); - } - - private static (ConditionEvaluator, Group) GetPredicate(string exactName, Match match) - { - var group = match.Groups["num_op"]; - if (group.Success) - { - return (GetPredicateForNumOp(exactName, group.ValueSpan), group); - } - - group = match.Groups["list_op"]; - if (group.Success) - { - return (GetPredicateForListOp(exactName, group.ValueSpan), group); - } - - group = match.Groups["op"]; - return (GetPredicateForStringOp(exactName, group.ValueSpan), group); - } - - private static ConditionEvaluator GetPredicateForNumOp(string _, ReadOnlySpan opString) - { - Func checkInt = opString switch - { - "#=" => (v1, v2, _) => v1 == v2, - "#!=" or "≠" or "≠" => (v1, v2, _) => v1 != v2, - "#>=" or ">=" or "≥" => (v1, v2, _) => v1 >= v2, - "#>" or ">" => (v1, v2, _) => v1 > v2, - "#<=" or "<=" or "≤" => (v1, v2, _) => v1 <= v2, - "#<" or "<" => (v1, v2, _) => v1 < v2, - _ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values - }; - return (v1, v2, culture) => ToIntObject(v1) is { } i1 && ToIntObject(v2) is { } i2 && checkInt(i1, i2, culture); - } - - private static int? ToIntObject(object? value) - { - return value switch - { - null => null, - IEnumerable e => e.Count(), - TimeSpan ts => (int)ts.TotalMinutes, - DateTime dt => (int)dt.ToOADate(), - string s => s.Length, - int i => i, - _ => null // language and such shall never match - }; - } - - private static ConditionEvaluator GetPredicateForListOp(string _, ReadOnlySpan opString) - { - var checklist = opString switch - { - "∋" or ">>" or ":contains:" => Swap>(IsSubset), - "⊇" or ">=>" or ":superset:" => Swap>(IsSubset), - "⊃" or ">->" or ":proper_superset:" => Swap>(IsProperSubset), - "∌" or "!>>" or "∌" or ":not_contains:" => Invert(Swap>(IsSubset)), - "∈" or "<<" or ":in:" => IsSubset, - "⊆" or "<=<" or ":subset:" => IsSubset, - "⊂" or "<-<" or ":proper_subset:" => IsProperSubset, - "∉" or "!<<" or "∉" or ":not_in:" => Invert>(IsSubset), - "⋂" or "&&" or ":overlaps:" => Overlaps, - "⋂̸" or "&&!" or "⋂!" or ":disjoint:" => Invert>(Overlaps), - "≡" or "==" or ":equals:" => (e1, e2, culture) => - { - var cmp = GetStringComparer(culture); - return e1.OrderBy(e => e, cmp).SequenceEqual(e2.OrderBy(e => e, cmp), cmp); - } - , - _ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values - }; - return (v1, v2, culture) => v1 is not null && v2 is not null && checklist(ToEnumerable(v1), ToEnumerable(v2), culture); - } - - private static IEnumerable ToEnumerable(object value) - { - return value switch - { - IEnumerable e => e, - IEnumerable e => e.Select(o => o.ToString() ?? ""), - string s => [s], - _ => [value.ToString() ?? ""] - }; - } - - private static ConditionEvaluator GetPredicateForStringOp(string exactName, ReadOnlySpan opString) - { - var checkItem = opString switch - { - "=" or "" => GetStringEqCheck(), - "!=" or "!" => Invert(GetStringEqCheck()), - "=~" or "~" => GetRegExpCheck(exactName), - "!~" => Invert(GetRegExpCheck(exactName)), - _ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values - }; - return (v1, v2, culture) => (v1, v2) switch - { - (null, _) => false, - (_, null) => false, - (IEnumerable e1, _) => e1.Any(l => checkItem(l, v2, culture)), - (_, IEnumerable e2) => e2.Any(r => checkItem(v1, r, culture)), - _ => checkItem(v1, v2, culture) - }; - } - - private static bool Overlaps(IEnumerable e1, IEnumerable e2, CultureInfo? culture) - { - var comparer = GetStringComparer(culture); - return e1.Any(l => e2.Contains(l, comparer)); - } - - private static bool IsSubset(IEnumerable e1, IEnumerable e2, CultureInfo? culture) - { - var comparer = GetStringComparer(culture); - return e1.All(l => e2.Contains(l, comparer)); - } - - private static bool IsProperSubset(IEnumerable e1, IEnumerable e2, CultureInfo? culture) - { - var comparer = GetStringComparer(culture); - // ReSharper disable PossibleMultipleEnumeration - return e1.All(l => e2.Contains(l, comparer)) && e2.Any(r => !e1.Contains(r, comparer)); - // ReSharper restore PossibleMultipleEnumeration - } - - private static Func Invert(Func condition) => (v1, v2, culture) => !condition(v1, v2, culture); - private static Func Swap(Func condition) => (v1, v2, culture) => condition(v2, v1, culture); - - private static Func GetStringEqCheck() - { - return (v1, v2, culture) => GetStringComparer(culture).Equals(ValueToString(v1, culture), ValueToString(v2, culture)); - } - - private static string? ValueToString(object value, CultureInfo? culture) - { - return value switch - { - TimeSpan ts => ts.TotalMinutes.ToString("0", culture), - IFormattable f => f.ToString(null, culture), - _ => value.ToString() - }; - } - - private static StringComparer GetStringComparer(CultureInfo? culture) - { - return StringComparer.Create(culture ?? CultureInfo.CurrentCulture, ignoreCase: true); - } - - /// - /// Build a regular expression check. Uses culture-invariant matching for thread-safety and consistency. - /// Applies a timeout to prevent regex patterns from causing excessive backtracking and blocking. - /// Throws InvalidOperationException if the regex pattern is invalid or evaluation times out. - /// - /// The full tag string for context in error messages - /// check function to validate an object - /// Thrown when regex parsing fails or when regex matching times out, indicating faulty user input - private static Func GetRegExpCheck(string exactName) - { - return (v1, v2, _) => - { - var pattern = v2.ToString() ?? ""; - var regex = RegexCache.GetOrAdd(pattern, p => - { - try - { - // Compile regex with timeout to prevent catastrophic backtracking - return new Regex(p, - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, - RegexpCheckTimeout); - } - catch (ArgumentException ex) - { - // If regex compilation fails, throw as faulty user input - var errorMessage = BuildErrorMessage(exactName, p, "Invalid regular expression pattern. Correct the pattern and escaping or remove that condition"); - throw new InvalidOperationException(errorMessage, ex); - } - }); - try - { - // CultureInfo parameter is intentionally ignored (discarded with _). - // RegexOptions.CultureInvariant ensures culture-independent matching for predictable behavior. - // This is preferred for template conditions because: - // 1. Thread-safety: Regex operations are isolated and don't depend on thread-local culture - // 2. Consistency: Template matches produce identical results regardless of system locale - // 3. Predictability: Rules don't unexpectedly change based on user's OS settings - // - // Culture-sensitive matching would be problematic in cases like: - // - Turkish locale: 'I' has different case folding (I ↔ ı vs. I ↔ i). Pattern "[i-z]" might match Turkish 'ı'. - // - German locale: ß might be treated as equivalent to 'ss' during case-insensitive matching. - // - Lithuanian locale: 'i' after 'ž' has an accent that affects sorting/matching. - // - // For naming templates, culture-invariant is the safer default. - return regex.IsMatch(v1.ToString() ?? ""); - } - catch (RegexMatchTimeoutException ex) - { - // Throw if regex evaluation times out, indicating faulty user input (e.g., catastrophic backtracking) - var errorMessage = BuildErrorMessage(exactName, pattern, "Regular expression pattern evaluation timed out. Use a simpler pattern or remove that condition"); - throw new InvalidOperationException(errorMessage, ex); - } - }; - } - - private static string BuildErrorMessage(string exactName, string pattern, string errorType) - { - const int maxMessageLen = 200; - - // Build a full message with the pattern - var fullMsg = $"{errorType}: {exactName} -> Pattern: {pattern}"; - - // Return a full message if it's within the character limit - if (fullMsg.Length <= maxMessageLen) return fullMsg; - - // Keep the error type and as much pattern as possible - var maxPatternLen = maxMessageLen - errorType.Length - 23; // Account for ". Pattern starts with: " - var trimmedPattern = pattern.Length > maxPatternLen ? pattern[..(maxPatternLen - 3)] + "..." : pattern; - return $"{errorType}. Pattern starts with: {trimmedPattern}"; - - } - // without any special check, only the existence of the property is checked. Strings need to be non-empty. public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag) @@ -486,32 +232,5 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) matchData.GetValueOrDefault("second_property")?.ValueOrNull()); return matchData["not"].Success ? Expression.Not(getBool) : getBool; } - - [GeneratedRegex(""" - (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # - ^(?>(?(? # anchor at start of line. Capture operator in , and with every char escapable - ≡ | == | :equals: # - list operators: ≡ for checking if two lists contain the same items regardless of order - | ∌ | !>> | ∌ | :not_contains: # - list operators: ∌ for checking if the first list does not contain any item of the second list - | ∋ | >> | :contains: # - list operators: ∋ for checking if the first list contains all items of the second list - | ∉ | !<< | ∉ | :not_in: # - list operators: ∉ for checking if the first list is not contained in the second list - | ∈ | << | :in: # - list operators: ∈ for checking if the first list is contained in the second list - | ⋂̸ | &&! | ⋂! | :disjoint: # - list operators: ⋂̸ for checking if the two lists are disjoint - | ⋂ | && | :overlaps: # - list operators: ⋂ for checking if the two lists overlap in at least one item - | ⊆ | <=< | :subset: # - list operators: ⊆ for checking if the first list is a subset of the second list (may be equal) - | ⊇ | >=> | :superset: # - list operators: ⊇ for checking if the first list is a superset of the second list (may be equal) - | ⊂ | <-< | :proper_subset: # - list operators: ⊂ for checking if the first list is a proper subset of the second list (not equal) - | ⊃ | >-> | :proper_superset: # - list operators: ⊃ for checking if the first list is a proper superset of the second list (not equal) - ) | (? - \#!?= | ≠ | ≠ # - numerical operators: #= #!= ≠ - | \#[<>]=? # - numerical operators: #<= #>= #< #> - | [<>]=? | ≤ | ≥ # - numerical operators: <= >= < > ≤ ≥ - ) | [=!]?~ | !=? | =? # - string comparison operators including ~ for regexp, = and !=. No operator is like = - )) \s*? # ignore space between operator and value - (?(?(num_op) # capture value in - (?:\d)+ # - numerical operators have to be followed by a number - | (?:\\.|[^\\])+ ) # - string for comparison. May be empty. Capturing also all whitespace up to the end as this must have been escaped. - )?$ # match to the end - """)] - private static partial Regex CheckRegex(); } } diff --git a/Source/FileManager/NamingTemplate/TagCollection.cs b/Source/FileManager/NamingTemplate/TagCollection.cs index 50ed8198..deda00fe 100644 --- a/Source/FileManager/NamingTemplate/TagCollection.cs +++ b/Source/FileManager/NamingTemplate/TagCollection.cs @@ -1,6 +1,5 @@ using System; using System.Collections; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -13,8 +12,6 @@ namespace FileManager.NamingTemplate; /// A collection of s registered to a single . public abstract class TagCollection : IEnumerable { - protected static readonly ConcurrentDictionary RegexCache = new(); - /// The s registered with this public IEnumerator GetEnumerator() => PropertyTags.Select(p => p.TemplateTag).GetEnumerator(); diff --git a/Source/FileManager/ReplacementCharacters.cs b/Source/FileManager/ReplacementCharacters.cs index b46947e6..8609e1b8 100644 --- a/Source/FileManager/ReplacementCharacters.cs +++ b/Source/FileManager/ReplacementCharacters.cs @@ -241,7 +241,7 @@ public class ReplacementCharacters if (CharIsPathInvalid(c) || invalidSlashes.Contains(c) - || Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ ) + || Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that the user wants. */) { char preceding = i > 0 ? fileName[i - 1] : default; char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default; @@ -265,7 +265,7 @@ public class ReplacementCharacters if ( ( CharIsPathInvalid(c) - || ( // Replace any other legal characters that they user wants. + || ( // Replace any other legal characters that the user wants. c != Path.DirectorySeparatorChar && c != Path.AltDirectorySeparatorChar && Replacements.Any(r => r.CharacterToReplace == c) diff --git a/Source/LibationFileManager/Templates/ContributorDto.cs b/Source/LibationFileManager/Templates/ContributorDto.cs index 0fcdc361..8b0e6cf6 100644 --- a/Source/LibationFileManager/Templates/ContributorDto.cs +++ b/Source/LibationFileManager/Templates/ContributorDto.cs @@ -10,6 +10,8 @@ public class ContributorDto(string name, string? audibleContributorId) : IFormat private HumanName HumanName { get; } = new(RemoveSuffix(name), Prefer.FirstOverPrefix); private string? AudibleContributorId { get; } = audibleContributorId; + private const string DefaultFormat = "{T} {F} {M} {L} {S}"; + public static readonly Dictionary> FormatReplacements = new(StringComparer.OrdinalIgnoreCase) { // Single-word names parse as first names. Use it as last name. @@ -23,12 +25,10 @@ public class ContributorDto(string name, string? audibleContributorId) : IFormat { "ID", dto => dto.AudibleContributorId }, }; - public override string ToString() => ToString("{T} {F} {M} {L} {S}", null); + public override string ToString() => ToString(null, null); public string ToString(string? format, IFormatProvider? provider) - => string.IsNullOrWhiteSpace(format) - ? ToString() - : CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements); + => CommonFormatters.TemplateStringFormatter(this, string.IsNullOrWhiteSpace(format) ? DefaultFormat : format, provider, FormatReplacements); private static string RemoveSuffix(string namesString) { diff --git a/Source/LibationFileManager/Templates/IListFormat[TList].cs b/Source/LibationFileManager/Templates/IListFormat[TList].cs index 2cbd9358..ff3c70d5 100644 --- a/Source/LibationFileManager/Templates/IListFormat[TList].cs +++ b/Source/LibationFileManager/Templates/IListFormat[TList].cs @@ -3,22 +3,34 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; -using static FileManager.NamingTemplate.RegExpExtensions; +using FileManager.NamingTemplate; namespace LibationFileManager.Templates; internal partial interface IListFormat where TList : IListFormat { - static IEnumerable FilteredList(string formatString, IEnumerable items) + static IEnumerable FilteredList(string formatString, IEnumerable items, CultureInfo? culture) where T : IFormattable { - return Max(formatString, Slice(formatString, items)); + return Max(formatString, Slice(formatString, Unique(formatString, items, culture))); + + static StringComparer GetStringComparer(CultureInfo? culture) + { + return StringComparer.Create(culture ?? CultureInfo.CurrentCulture, ignoreCase: true); + } + + static IEnumerable Unique(string formatString, IEnumerable items, CultureInfo? culture) + { + return UniqueRegex().TryMatch(formatString, out var uniqueMatch) + ? items.DistinctBy(n => n.ToString(uniqueMatch.ResolveValue("format"), culture), GetStringComparer(culture)) + : items; + } static IEnumerable Slice(string formatString, IEnumerable items) { if (!SliceRegex().TryMatch(formatString, out var sliceMatch)) return items; - int.TryParse(sliceMatch.Groups["first"].ValueSpan, out var first); - int.TryParse(sliceMatch.Groups["last"].ValueSpan, out var last); + sliceMatch.TryParseInt("first", out var first); + sliceMatch.TryParseInt("last", out var last); if (!sliceMatch.Groups["op"].Success) last = first; if (last > 0) @@ -52,11 +64,20 @@ internal partial interface IListFormat where TList : IListFormat static IEnumerable FormattedList(string? formatString, IEnumerable items, CultureInfo? culture) where T : IFormattable { if (formatString is null) return items.Select(n => n.ToString(null, culture)); - var format = TList.FormatRegex().Match(formatString).ResolveValue("format"); - var separator = SeparatorRegex().Match(formatString).UnescapeValueOrNull("separator"); - var formattedItems = FilteredList(formatString, items).Select(ItemFormatter); + var filteredList = FilteredList(formatString, items, culture); + + if (CountRegex().TryMatch(formatString, out var countMatch)) + { + var count = filteredList.Count(); + return count == 0 ? [] : [CommonFormatters._FloatFormatter(count, countMatch.ResolveValue("format"), culture)]; + } + + var format = TList.FormatRegex().Match(formatString).ResolveValue("format"); + var formattedItems = filteredList.Select(ItemFormatter); + var separator = SeparatorRegex().Match(formatString).UnescapeValueOrNull("separator"); + if (separator is null) + return formattedItems; - if (separator is null) return formattedItems; var joined = Join(separator, formattedItems); return joined is null ? [] : [joined]; @@ -98,4 +119,12 @@ internal partial interface IListFormat where TList : IListFormat /// Separator can be anything [GeneratedRegex("""[Ss]eparator\((?(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)\)""")] private static partial Regex SeparatorRegex(); + + /// Count will substitute all list members with a single number equal to there count + [GeneratedRegex("""[Cc]ount\((?(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)\)""")] + private static partial Regex CountRegex(); + + /// Unique will shrink the list to unique members after applying format to them + [GeneratedRegex("""[Uu]nique\((?(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)\)""")] + private static partial Regex UniqueRegex(); } diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index adc1d1f4..dbcaa794 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -181,6 +181,7 @@ namespace TemplatesTests [TestMethod] [DataRow("", "")] [DataRow("", "")] + [DataRow("", "")] [DataRow("", "")] [DataRow("", "")] [DataRow("", "")] @@ -398,6 +399,16 @@ namespace TemplatesTests [DataRow("", "Jon Bon Jovi, Paul Van Doren")] [DataRow("", "Charles E. Gannon, Emma Gannon")] [DataRow("", "Emma Gannon, Charles E. Gannon")] + [DataRow("", "Browne, Gannon, Fetherolf, Montgomery, Van Doren")] + [DataRow("", "7")] + [DataRow("", "7")] + [DataRow("", "2")] + [DataRow("", "007")] + [DataRow("", "007")] + [DataRow("", "007")] + [DataRow("", "007")] + [DataRow(" ", "Browne, Gannon and 5 more")] + [DataRow(" ", "Browne, Gannon, Fetherolf, Montgomery, Bon Jovi, Van Doren, Gannon ")] [DataRow("", "Browne, Jill, Gannon, Charles, Fetherolf, Christopher, Montgomery, Lucy, Bon Jovi, Jon, Van Doren, Paul, Gannon, Emma")] [DataRow("", "Browne, Jill B1, Gannon, Charles B2, Fetherolf, Christopher B3, Montgomery, Lucy B4, Bon Jovi, Jon B5, Van Doren, Paul B6, Gannon, Emma B7")] [DataRow("", "B1, B2, B3, B4, B5, B6, B7")] @@ -708,6 +719,9 @@ namespace TemplatesTests [DataRow("", "Series A, Series B, Series C, Series D")] [DataRow("", "Series A, Series B, Series C, Series D")] [DataRow("", "Series B, Series C")] + [DataRow("", "04")] + [DataRow("", "Series D")] + [DataRow("", "1")] [DataRow("", "Series A")] [DataRow("", "Series A, Series B")] [DataRow("", "Series A, Series B, Series C")] @@ -828,6 +842,8 @@ namespace TemplatesTests [DataRow("", "I", "en-US", "i")] [DataRow("", "ı", "tr-TR", "I")] [DataRow("", "İ", "tr-TR", "i")] + [DataRow("", "Isaac Asimov", "tr-TR", "any")] + [DataRow("", "ısaac ASİMOV", "tr-TR", "any")] [DataRow(@"", "8.573,30E1-0.021,00-9", "es-ES", "any")] [DataRow(@"", "8,573.30E1-0,021.00-9", "en-AU", "any")] [DataRow("", "44,100Hz ", "en-CA", "any")] @@ -838,6 +854,7 @@ namespace TemplatesTests var bookDto = Shared.GetLibraryBook(); bookDto.Title = title; bookDto.LengthInMinutes = TimeSpan.FromMinutes(123456789); + bookDto.Authors = [new("Isaac Asimov", "B00IA42MOV")]; var culture = new CultureInfo(cultureName); Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); @@ -853,6 +870,9 @@ namespace TemplatesTests [DataRow("", "TAG1, TAG2, TAG3")] [DataRow("", "tag1, tag2, tag3")] [DataRow("", "Tag: Tag1, Tag: Tag2, Tag: Tag3")] + [DataRow("", "03")] + [DataRow("", "Tag3")] + [DataRow("", "1")] [DataRow("", "Tag1")] [DataRow("", "Tag2, Tag3")] [DataRow("", "Tag3, Tag2, Tag1")] diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index 6dc6e0ef..726e0f35 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -131,20 +131,24 @@ Text formatting can change length and case of the text. Use \<#\>, \<#\>\ ### Text List Formatters -| Formatter | Description | Example Usage | Example Result | -|----------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |--------------------------------------------- | ---------------------------------------------| -| separator() | Specify the text used to join
multiple entries.

Default is ", " | `` | Tag1_Tag2_Tag3_Tag4_Tag5 | -| format(\{S\}) **†** | Formats the entries by placing their values into the specified template.
Use \{S:[Text formatters](#text-formatters)\} to place the entry and optionally apply a format. | ``separator(;)]>` | Tag=tag1;Tag=tag2;Tag=tag3;Tag=tag4;Tag=tag5 | -| sort(S) | Sorts the elements by their value.

*Sorting direction:*
uppercase = ascending
lowercase = descending

Default is unsorted | ``separator(;)]>` | Tag5;Tag4;Tag3;Tag2;Tag1 | -| max(#) | Only use the first # of entries | `` | Tag1 | -| slice(#) | Only use the nth entry of the list | `` | Tag2 | -| slice(#..) | Only use entries of the list starting from # | `` | Tag2, Tag3, Tag4, Tag5 | -| slice(..#) | Like max(#). Only use the first # of entries | `` | Tag1 | -| slice(#..#) | Only use entries of the list starting from # and ending at # (inclusive) | `` | Tag2, Tag3, Tag4 | -| slice(-#..-#) | Numbers may be specified negative. In that case positions ar counted from the end with -1 pointing at the last member | `` | Tag3, Tag4 | +| Formatter | Description | Example Usage | Example Result | +|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|----------------------------------------------| +| separator() | Specify the text used to join
multiple entries.

Default is ", " | `` | Tag1_Tag2_Tag3_Tag4_Tag5 | +| format(\{S\}) **†** | Formats the entries by placing their values into the specified template.
Use \{S:[Text formatters](#text-formatters)\} to place the entry and optionally apply a format. | ``separator(;)]>` | Tag=tag1;Tag=tag2;Tag=tag3;Tag=tag4;Tag=tag5 | +| unique(FMT) **†** | Reduce list members to a unique set. Entries are compared to each other after applying the given format. Duplicate entries (after format is applied) are removed, keeping the first occurrence. | ``
``separator(;)]>` | Tag1, Tag2, Tag3
tag1 | +| sort(S) | Sorts the elements by their value.

*Sorting direction:*
uppercase = ascending
lowercase = descending

Default is unsorted | ``separator(;)]>` | Tag5;Tag4;Tag3;Tag2;Tag1 | +| max(#) | Only use the first # of entries | `` | Tag1 | +| slice(#) | Only use the nth entry of the list | `` | Tag2 | +| slice(#..) | Only use entries of the list starting from # | `` | Tag2, Tag3, Tag4, Tag5 | +| slice(..#) | Like max(#). Only use the first # of entries | `` | Tag1 | +| slice(#..#) | Only use entries of the list starting from # and ending at # (inclusive) | `` | Tag2, Tag3, Tag4 | +| slice(-#..-#) | Numbers may be specified negative. In that case positions ar counted from the end with -1 pointing at the last member | `` | Tag3, Tag4 | +| count(FMT) **‡** | Instead of returning some or all members of the list, print out the number of entries using the specified [format](#number-formatters). | ``
`` | 5
05 | **†** For further information on format templates, please refer to the [Format templates](#format-templates) section. +**‡** When `count(FMT)` is used, only the number is output in the specified [number format](#number-formatters). Any other output-related specifications from `separator()` or `format()` are irrelevant here and are ignored. + ### Series Formatters | Formatter | Description | Example Usage | Example Result | @@ -156,15 +160,19 @@ Text formatting can change length and case of the text. Use \<#\>, \<#\>\ ### Series List Formatters | Formatter | Description | Example Usage | Example Result | -|---------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +|---------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |-------------------------------------------------------------------------------------------| ------------------------------------------------------------------------------------------------------------------- | | separator() | Specify the text used to join
multiple series names.

Default is ", " | `` | Sherlock Holmes; Some Other Series | | format(\{N \| # \| ID\}) **†** | Formats the series properties
using the name series tags.
See [Series Formatter Usage](#series-formatters) above. | ``separator(; )]>`
`` | Sherlock Holmes, 1-6; Book Collection, 1
B08376S3R2-Sherlock Holmes, 01.0-06.0, B000000000-Book Collection, 01.0 | +| unique(FMT) **†** | Reduce list members to a unique set. Entries are compared to each other after applying the given format. Duplicate entries (after format is applied) are removed, keeping the first occurrence. | ``
``separator(; )]>` | Sherlock Holmes; Some Other Series
sherlock holmes; some other series | | sort(N \| # \| ID) | Sorts the series by name, number or ID.

These terms define the primary, secondary, tertiary, … sorting order.
You may combine multiple terms in sequence to specify multi‑level sorting.

*Sorting direction:*
uppercase = ascending
lowercase = descending

Default is unsorted | ``separator(; )]>` | Book Collection, 1; Sherlock Holmes, 1-6 | | max(#) | Only use the first # of series | `` | Sherlock Holmes | | slice(#..#) | Only use entries of the series list starting from # and ending at # (inclusive)

See [Text List Formatter Usage](#Text-List-Formatters) above for details on all the variants of `slice()` | `` | Sherlock Holmes | +| count(FMT) **‡** | Instead of returning some or all members of the list, print out the number of series using the specified [format](#number-formatters). | ``
`` | 2
02 | **†** For further information on format templates, please refer to the [Format templates](#format-templates) section. +**‡** When `count(FMT)` is used, only the number is output in the specified [number format](#number-formatters). Any other output-related specifications from `separator()` or `format()` are irrelevant here and are ignored. + ### Name Formatters | Formatter | Description | Example Usage | Example Result | @@ -176,15 +184,19 @@ Text formatting can change length and case of the text. Use \<#\>, \<#\>\ ### Name List Formatters | Formatter | Description | Example Usage | Example Result | -|------------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +|------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------| | separator() | Specify the text used to join
multiple people's names.

Default is ", " | `` | Arthur Conan Doyle; Stephen Fry | | format(\{T \| F \| M \| L \| S \| ID\}) **†** | Formats the human name using
the name part tags.
See [Name Formatter Usage](#name-formatters) above. | ``separator(; )]>`
``_{ID}_) separator(; )]>` | DOYLE, Arthur; FRY, Stephen
Doyle, A. \_B000AQ43GQ\_;
Fry, S. \_B000APAGVS\_ | -| sort(T \| F \| M \| L \| S \| ID) | Sorts the names by title,
first, middle, or last name,
suffix or Audible Contributor ID

These terms define the primary, secondary, tertiary, … sorting order.
You may combine multiple terms in sequence to specify multi‑level sorting.

*Sorting direction:*
uppercase = ascending
lowercase = descending

Default is unsorted | ``
``
`` | Stephen Fry, Arthur Conan Doyle
Stephen King, Stephen Fry
John P. Smith \_B000TTTBBB\_, John P. Smith \_B000TTTCCC\_, John S. Smith \_B000HHHVVV\_ | +| unique(FMT) **†** | Reduce list members to a unique set. Entries are compared to each other after applying the given format. Duplicate entries (after format is applied) are removed, keeping the first occurrence. | ``
``separator(; )]>` | Arthur Conan Doyle, Stephen Fry
doyle; fry | +| sort(T \| F \| M \| L \| S \| ID) | Sorts the names by title,
first, middle, or last name,
suffix or Audible Contributor ID

These terms define the primary, secondary, tertiary, … sorting order.
You may combine multiple terms in sequence to specify multi‑level sorting.

*Sorting direction:*
uppercase = ascending
lowercase = descending

Default is unsorted | ``
``
`` | Stephen Fry, Arthur Conan Doyle
Stephen King, Stephen Fry
John P. Smith \_B000TTTBBB\_, John P. Smith \_B000TTTCCC\_, John S. Smith \_B000HHHVVV\_ | | max(#) | Only use the first # of names

Default is all names | `` | Arthur Conan Doyle | | slice(#..#) | Only use entries of the names list starting from # and ending at # (inclusive)

See [Text List Formatter Usage](#Text-List-Formatters) above for details on all the variants of `slice()` | `` | Arthur Conan Doyle | +| count(FMT) **‡** | Instead of returning some or all members of the list, print out the number of names using the specified [format](#number-formatters). | ``
`` | 2
02 | **†** For further information on format templates, please refer to the [Format templates](#format-templates) section. +**‡** When `count(FMT)` is used, only the number is output in the specified [number format](#number-formatters). Any other output-related specifications from `separator()` or `format()` are irrelevant here and are ignored. + ### TimeSpan Formatters For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-timespan-format-strings). @@ -268,7 +280,7 @@ You can specify which part of a language you are interested in. Depending on which property is to be displayed, one or more placeholders can be used in a format template. The placeholders are defined in the form `{A}`: -`` +`` The format template must sometimes be enclosed in square brackets and sometimes in round brackets. In addition to placeholders, the format template may also contain arbitrary text. To prevent this text from being mistaken for a bracket at the end of the template or a placeholder, escapes can be used within the text: * `\x` - Escapes the next character. @@ -276,7 +288,7 @@ The format template must sometimes be enclosed in square brackets and sometimes * `"text"` - encloses text that may contain special characters. To include a double quote in the text, escape it by doubling it: `"She said ""Hello"""` will output `She said "Hello"`. * `'text'`- encloses text that may contain special characters. To include a single quote in the text, escape it by doubling it: `'It''s a test'` will output `It's a test`. -`` +`` Not all elements of a property are always present or have content. In this case, format templates would contain gaps after substitution. Groups of spaces are automatically merged. Other characters, however, remain unchanged. By doubling the curly brackets, you can specify text fragments before and after the placeholder, which are only used if the placeholder is replaced with content.