diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 8ed37a69..51e18e7f 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -26,7 +26,7 @@ internal interface IClosingPropertyTag : IPropertyTag public delegate object? ValueProvider(ITemplateTag templateTag, T value, string condition, CultureInfo? culture); -public delegate bool ConditionEvaluator(object? value, CultureInfo? culture); +public delegate bool ConditionEvaluator(object? value1, object? value2, CultureInfo? culture); public partial class ConditionalTagCollection(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive) { @@ -63,6 +63,17 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider)); } + /// + /// Register a conditional tag. + /// + /// + /// A to get the first condition's value. The values will be evaluated by a check specified by the tag itself. + /// A to get the second condition's value. The values will be evaluated by a check specified by the tag itself. + public void Add(ITemplateTag templateTag, ValueProvider valueProvider1, ValueProvider valueProvider2) + { + AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider1, valueProvider2)); + } + private partial class ConditionalTag : TagBase, IClosingPropertyTag { private static readonly TimeSpan RegexpCheckTimeout = TimeSpan.FromMilliseconds(100); @@ -70,7 +81,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) public override Regex NameMatcher { get; } public Regex NameCloseMatcher { get; } - private Func CreateConditionExpression { get; } + private Func CreateConditionExpression { get; } public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression) : base(templateTag, conditionExpression) @@ -79,7 +90,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) NameMatcher = new Regex($"^<(?!)?{tagNameRe}->", options); NameCloseMatcher = new Regex($"^<-{tagNameRe}>", options); - CreateConditionExpression = (_, _, _) => conditionExpression; + CreateConditionExpression = (_, _, _, _) => conditionExpression; } public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider valueProvider, ConditionEvaluator conditionEvaluator) @@ -88,7 +99,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) // needs to match on at least one character, which is not a space NameMatcher = new Regex($""" (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # - ^<(?!)? # tags start with a '<'. Condtionals allow an optional ! captured in to negate the condition + ^<(?!)? # tags start with a '<'. Conditionals allow an optional ! captured in to negate the condition {TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#' (?:\s+ # the following part is optional. If present it starts with some whitespace (?.+?) # - capture the non greedy so it won't end on whitespace, '[' or '-' (if match is possible) @@ -98,47 +109,85 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) , options); NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); - CreateConditionExpression = (_, property, _) - => ConditionEvaluatorCall(templateTag, parameter, valueProvider, property, conditionEvaluator); + CreateConditionExpression = (_, property, _, _) + => ConditionEvaluatorCall(conditionEvaluator, + ValueProviderCall(templateTag, parameter, valueProvider, property), + Expression.Constant(null)); } public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider valueProvider) : base(templateTag, Expression.Constant(false)) { // needs to match on at least one character, which is not a space. - // though we will capture the group named `check` enclosed in [] at the end of the tag, the property itself might also have a [] part for formatting purposes + // though we will capture the group named `check_or_op` enclosed in [] at the end of the tag, the property itself might also have a [] part for formatting purposes NameMatcher = new Regex($""" (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # - ^<(?!)? # tags start with a '<'. Condtionals allow an optional ! captured in to negate the condition + ^<(?!)? # tags start with a '<'. Conditionals allow an optional ! captured in to negate the condition {TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#' (?:\s+ # the following part is optional. If present it starts with some whitespace (?.+? # - capture the non greedy so it won't end on whitespace, '[' or '-' (if match is possible) (? end with a whitepace. Otherwise "" would be matchable. (?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall start with an operator. So match whitespace first - (? # - capture inner part as + (? # - capture inner part as (?:\\. # - '\' escapes always the next character. Especially further '\' and the closing ']' - |[^\\\]])* ) # - match any character except '\' and ']'. Check may end in whitespace! - \])? # - closing the check part - )? # end of optional property and check part + |[^\\\]])* ) # - match any character except '\' and ']'. check_or_op may end in whitespace! + \])? # - closing the check_or_op part + )? # end of optional property and check_or_op part \s*-> # Opening tags end with '->' and closing tags begin with '<-', so both sides visually point toward each other """ , options); NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); - CreateConditionExpression = (exactName, property, checkString) => + CreateConditionExpression = (exactName, property, checkString, _) => { - var conditionEvaluator = GetPredicate(exactName, checkString); - return ConditionEvaluatorCall(templateTag, parameter, valueProvider, property, conditionEvaluator); + var (value, conditionEvaluator) = GetPredicateAndValue(exactName, checkString); + return ConditionEvaluatorCall(conditionEvaluator, + ValueProviderCall(templateTag, parameter, valueProvider, property), + BuildArgument(value, conditionEvaluator.Method.GetParameters()[1].ParameterType)); }; } - private static MethodCallExpression ConditionEvaluatorCall(ITemplateTag templateTag, ParameterExpression parameter, ValueProvider valueProvider, string? property, - ConditionEvaluator conditionEvaluator) + public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider valueProvider1, ValueProvider valueProvider2) + : base(templateTag, Expression.Constant(false)) + { + NameMatcher = new Regex($""" + (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # + ^<(?!)? # tags start with a '<'. Conditionals allow an optional ! captured in to negate the condition + {TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#' + \s+ # Separate the following with whitespace + (? # capture the + '(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character + | "(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character + | (?: \[ (?: \\. # - properties may have optional formatting details enclosed in '[' and ']'. '\' escapes always the next character + | [^\\\]] # unescaped characters except ']' and '\' are allowed in the formatting details + )* \] # closing the formatting details part + | . )+? # - match any character to form the property name. Capture non greedy so it won't match the operator part. + (? end with a whitepace. Otherwise "" would be matchable. + \s+ # Separate the following operand with whitespace + (? # capture operator in and with every char escapable + [\#!≡=≠~<>≤≥&∉∌∈∌⋂⊆⊇⊂⊃-]+ # allow a wide range of operators, all non alphanumeric + | :[a-z_]+: # allow :named: operators for readability, e.g. :contains: + ) \s+ # ignore space between operator and second property + (?.+? # - capture the non greedy so it won't end on whitespace + (? end with a whitepace. Otherwise "" would be matchable. + \s*-> # Opening tags end with '->' and closing tags begin with '<-', so both sides visually point toward each other + """ + , options); + NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); + + CreateConditionExpression = (exactName, property1, checkString, property2) + => ConditionEvaluatorCall(GetPredicate(exactName, checkString), + ValueProviderCall(templateTag, parameter, valueProvider1, property1), + ValueProviderCall(templateTag, parameter, valueProvider2, property2)); + } + + private static MethodCallExpression ConditionEvaluatorCall(ConditionEvaluator conditionEvaluator, Expression valueExpression1, Expression valueExpression2) { return Expression.Call( conditionEvaluator.Target is null ? null : Expression.Constant(conditionEvaluator.Target), conditionEvaluator.Method, - ValueProviderCall(templateTag, parameter, valueProvider, property), + valueExpression1, + valueExpression2, CultureParameter); } @@ -153,88 +202,220 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) CultureParameter); } - private static ConditionEvaluator GetPredicate(string exactName, string? checkString) + private static Expression BuildArgument(object value, Type targetType) { - if (checkString == null) - return (v, _) => v switch + var constant = Expression.Constant(value, value.GetType()); + 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(v.ToString()) - }; + _ => !string.IsNullOrWhiteSpace(v1.ToString()) + }); - var match = CheckRegex().Match(checkString); + var match = GetMatch(exactName, checkString); + var valStr = Unescape(match.Groups["val"]); + var (evaluator, opGroup) = GetPredicate(exactName, match); - var valStr = Unescape(match.Groups["val"]) ?? ""; - var iVal = -1; - var isNumericalOperator = match.Groups["num_op"].Success && int.TryParse(valStr, out iVal); - - var checkItem = Unescape(match.Groups["op"]) switch + return (opGroup.Name switch { - "=" or "" => (v, culture) => VComparedToStr(v, culture, valStr) == 0, - "!=" or "!" => (v, culture) => VComparedToStr(v, culture, valStr) != 0, - "~" => GetRegExpCheck(exactName, valStr), - "#=" => (v, _) => VAsInt(v) == iVal, - "#!=" => (v, _) => VAsInt(v) != iVal, - "#>=" or ">=" => (v, _) => VAsInt(v) >= iVal, - "#>" or ">" => (v, _) => VAsInt(v) > iVal, - "#<=" or "<=" => (v, _) => VAsInt(v) <= iVal, - "#<" or "<" => (v, _) => VAsInt(v) < iVal, - _ => (v, _) => !string.IsNullOrWhiteSpace(v.ToString()) - }; - return isNumericalOperator - ? (v, culture) => v switch - { - null => false, - IEnumerable e => checkItem(e.Count(), culture), - string s => checkItem(s.Length, culture), - TimeSpan ts => checkItem(ts.TotalMinutes, culture), - _ => checkItem(v, culture) - } - : (v, culture) => v switch - { - null => false, - IEnumerable e => e.Any(o => checkItem(o, culture)), - _ => checkItem(v, culture) - }; - - int? VAsInt(object v) => v is int iv ? iv : int.TryParse(v.ToString(), out var parsed) ? parsed : null; + "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 int VComparedToStr(object? v, CultureInfo? culture, string valStr) + private static ConditionEvaluator GetPredicate(string exactName, string? checkString) { - culture ??= CultureInfo.CurrentCulture; - return culture.CompareInfo.Compare(v?.ToString(), valStr, CompareOptions.IgnoreCase); + 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 - /// The regex pattern to match /// 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, string pattern) + private static Func GetRegExpCheck(string exactName) { - Regex regex; - try - { - // Compile regex with timeout to prevent catastrophic backtracking - regex = new Regex(pattern, - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, - RegexpCheckTimeout); - } - catch (ArgumentException ex) - { - // If regex compilation fails, throw as faulty user input - var errorMessage = BuildErrorMessage(exactName, pattern, "Invalid regular expression pattern. Correct the pattern and escaping or remove that condition"); - throw new InvalidOperationException(errorMessage, ex); - } - - return (v, _) => + 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 _). @@ -250,7 +431,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) // - Lithuanian locale: 'i' after 'ž' has an accent that affects sorting/matching. // // For naming templates, culture-invariant is the safer default. - return regex.IsMatch(v.ToString() ?? ""); + return regex.IsMatch(v1.ToString() ?? ""); } catch (RegexMatchTimeoutException ex) { @@ -265,10 +446,10 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) { const int maxMessageLen = 200; - // Build full message with pattern + // Build a full message with the pattern var fullMsg = $"{errorType}: {exactName} -> Pattern: {pattern}"; - // Return full message if it's within the character limit + // 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 @@ -300,22 +481,35 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) var getBool = CreateConditionExpression( exactName, matchData.GetValueOrDefault("property")?.Value, - matchData.GetValueOrDefault("check")?.ValueOrNull()); + matchData.GetValueOrDefault("check_or_op")?.ValueOrNull(), + 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 linecapture operator in and with every char escapable - \\?\#(?:\\?!)?\\?= # - 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 + (?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/RegExpExtensions.cs b/Source/FileManager/NamingTemplate/RegExpExtensions.cs index 57a21ef0..6d93813b 100644 --- a/Source/FileManager/NamingTemplate/RegExpExtensions.cs +++ b/Source/FileManager/NamingTemplate/RegExpExtensions.cs @@ -33,8 +33,13 @@ public static class RegExpExtensions extension(Regex regex) { - public bool TryMatch(string input, [NotNullWhen(true)] out Match? match) + public bool TryMatch(string? input, [NotNullWhen(true)] out Match? match) { + if (input is null) + { + match = null; + return false; + } var m = regex.Match(input); match = m.Success ? m : null; return m.Success; diff --git a/Source/FileManager/NamingTemplate/TagCollection.cs b/Source/FileManager/NamingTemplate/TagCollection.cs index a641edeb..50ed8198 100644 --- a/Source/FileManager/NamingTemplate/TagCollection.cs +++ b/Source/FileManager/NamingTemplate/TagCollection.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -12,6 +13,8 @@ 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(); @@ -79,7 +82,7 @@ public abstract class TagCollection : IEnumerable private protected void AddPropertyTag(IPropertyTag propertyTag) { - if (!PropertyTags.Any(c => c.TemplateTag.TagName == propertyTag.TemplateTag.TagName)) + if (PropertyTags.All(c => c.TemplateTag.TagName != propertyTag.TemplateTag.TagName)) PropertyTags.Add(propertyTag); } diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index 85a8cdd1..faffe03f 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -68,4 +68,7 @@ public sealed class TemplateTags : ITemplateTag public static TemplateTags IfAbridged { get; } = new("if abridged", "Only include if abridged", "<-if abridged>", "...<-if abridged>"); public static TemplateTags Has { get; } = new("has", "Only include if PROPERTY has a value (i.e. not null or empty)", "<-has>", "...<-has>"); public static TemplateTags Is { get; } = new("is", "Only include if PROPERTY has a value satisfying the check (i.e. string comparison)", "<-is>", "...<-is>"); + + public static TemplateTags Cmp { get; } = new("cmp", "Only include if first PROPERTY has a value satisfying the check (i.e. string comparison) against the second PROPERTY", "<-cmp>", + "...<-cmp>"); } diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 3dfb9f69..4da6ca34 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using AaxDecrypter; using Dinah.Core; using FileManager; @@ -19,7 +20,7 @@ public interface ITemplate static abstract IEnumerable TagCollections { get; } } -public abstract class Templates +public abstract partial class Templates { public const string ErrorFullPathIsInvalid = @"No colons or full paths allowed. Eg: should not start with C:\"; public const string WarningNoChapterNumberTag = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: or "; @@ -338,6 +339,7 @@ public abstract class Templates private static readonly ConditionalTagCollection combinedConditionalTags = new() { { TemplateTags.Is, TryGetValue }, + { TemplateTags.Cmp, TryGetValue, TryGetValue }, { TemplateTags.Has, TryGetValue, HasValue } }; @@ -351,6 +353,23 @@ public abstract class Templates private static object? TryGetValue(ITemplateTag _, CombinedDto dtos, string property, CultureInfo? culture) { + // check for string literal first + if (StringValueRegex().TryMatch(property, out var stringValue)) + { + // inside the quotes, doubled quotes are used to represent literal quotes. So replace them back to single quotes if there are any. + // this match helps to determine which quote type is being used so that the correct one can be replaced. + var doubleQuote = stringValue.Groups["double"]; + return doubleQuote.Success + ? stringValue.Groups["value"].Value.Replace(doubleQuote.Value, stringValue.Groups["quote"].Value) + : stringValue.Groups["value"].Value; + } + // then check for int literal + if (int.TryParse(property, out var intVal)) + { + return intVal; + } + + // then check for property tags and retrieve their value foreach (var c in allPropertyTags.OfType>()) { if (c.TryGetObject(property, dtos.LibraryBook, culture, out var value)) @@ -369,7 +388,10 @@ public abstract class Templates return null; } - private static bool HasValue(object? value, CultureInfo? culture) + [GeneratedRegex(@"^\s*(?['""])(?(?:(?\k{2})|.)*)\k\s*$")] + private static partial Regex StringValueRegex(); + + private static bool HasValue(object? value, object? _, CultureInfo? culture) { bool CheckItem(object o, CultureInfo? _) => !string.IsNullOrWhiteSpace(o.ToString()); return value switch diff --git a/Source/_Tests/FileManager.Tests/ConditionalTagCollectionTests.cs b/Source/_Tests/FileManager.Tests/ConditionalTagCollectionTests.cs index eb9919af..2ede9eea 100644 --- a/Source/_Tests/FileManager.Tests/ConditionalTagCollectionTests.cs +++ b/Source/_Tests/FileManager.Tests/ConditionalTagCollectionTests.cs @@ -27,6 +27,33 @@ public class ConditionalTagCollectionTests private static object? TryGetValue(ITemplateTag _, TestObject obj, string condition, CultureInfo? culture) => obj.Value; + /// + /// Test that invalid regex patterns throw InvalidOperationException during evaluation. + /// Tests include malformed patterns and catastrophic backtracking scenarios. + /// + [TestMethod] + [DataRow("#=foo", DisplayName = "NumericalCheck_on_string")] + [DataRow("#=", DisplayName = "NumericalCheck_without_number")] + public void ConditionalTag_InvalidOperator_ThrowsInvalidOperationException(string check) + { + // Arrange: Invalid check that should throw InvalidOperationException during evaluation + var template = $"content<-testcond>"; + var namingTemplate = NamingTemplate.NamingTemplate.Parse(template, [_conditionalTags]); + + var testObj = new TestObject { Value = "testValue" }; + + // Act & Assert: Evaluate template with invalid check, should throw InvalidOperationException + try + { + namingTemplate.Evaluate(testObj); + Assert.Fail($"Expected InvalidOperationException for check: {check}"); + } + catch (InvalidOperationException) + { + // Expected behavior - regex is invalid and parsing should fail + } + } + /// /// Test that invalid regex patterns throw InvalidOperationException during evaluation. /// Tests include malformed patterns and catastrophic backtracking scenarios. diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs index e4d9d2a0..ba796f7e 100644 --- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs +++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs @@ -107,7 +107,7 @@ public class GetPortionFilename private static object? TryGetValue(ITemplateTag templateTag, PropertyClass3 referenceType, string condition, CultureInfo? culture) => props3.TryGetObject(condition, referenceType, culture, out var value) ? value : null; - private static bool HasValue(object? value, CultureInfo? culture) => value is not null && !string.IsNullOrWhiteSpace(value.ToString()); + private static bool HasValue(object? value, object? value2, CultureInfo? culture) => value is not null && !string.IsNullOrWhiteSpace(value.ToString()); private readonly PropertyClass1 _propertyClass1 = new() { diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index c06540f0..c755c5e8 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -545,15 +545,19 @@ namespace TemplatesTests [DataRow("true<-is>", "true")] [DataRow("true<-is>", "true")] [DataRow("true<-is>", "true")] + [DataRow("=3]->true<-is>", "")] [DataRow("=2]->true<-is>", "true")] [DataRow("true<-is>", "true")] [DataRow("true<-is>", "true")] [DataRow("true<-is>", "true")] + [DataRow("true<-is>", "")] + [DataRow(">]->true<-is>", "")] + [DataRow("true<-is>", "true")] + [DataRow(">]->true<-is>", "true")] [DataRow("false<-is>", "")] [DataRow("true<-is>", "true")] [DataRow("false<-is>", "")] [DataRow("true<-is>", "true")] - [DataRow("=3]->true<-is>", "")] [DataRow(@"true<-is>", "")] [DataRow("true<-is>", "")] [DataRow("true<-is>", "")] @@ -561,22 +565,30 @@ namespace TemplatesTests [DataRow("true<-is>", "")] [DataRow("true<-is>", "")] [DataRow("true<-is>", "")] + [DataRow("true<-is>", "")] [DataRow("false<-is>", "false")] + [DataRow("false<-is>", "false")] [DataRow("true<-is>", "true")] + [DataRow("true<-is>", "true")] [DataRow("false<-is>", "")] + [DataRow("false<-is>", "")] [DataRow("true<-is>", "true")] [DataRow("true<-is>", "true")] - [DataRow("false<-is>", "")] + [DataRow("true<-is>", "")] [DataRow("true<-is>", "true")] [DataRow(@"true<-is>", "true")] - [DataRow("false<-is>", "")] - [DataRow("false<-is>", "")] - [DataRow(@"false<-is>", "")] - [DataRow(@"false<-is>", "")] - [DataRow("false<-is>", "")] + [DataRow("true<-is>", "")] + [DataRow("true<-is>", "")] + [DataRow(@"true<-is>", "")] + [DataRow(@"true<-is>", "")] + [DataRow("true<-is>", "")] [DataRow("true<-is>", "true")] [DataRow(@"true<-is>", "true")] - [DataRow(@"42]->true<-is>", "true")] + [DataRow("42]->true<-is>", "true")] + [DataRow("9000]->true<-is>", "true")] + [DataRow("90000]->true<-is>", "")] + [DataRow("42]->true<-is>", "")] + [DataRow("42]->true<-is>", "")] public void HasValue_test(string template, string expected) { var bookDto = GetLibraryBook(); @@ -597,6 +609,93 @@ namespace TemplatesTests fileTemplate.Warnings.Should().HaveCount(1); // "Should use tags. Eg: " } + [TestMethod] + [DataRow("<cmp title = 'A Study in Scarlet: An Audible Original Drama'->true<-cmp>", "true")] + [DataRow("<!cmp title = 'A Study in Scarlet: An Audible Original Drama'->false<-cmp>", "")] + [DataRow("<cmp title #= 45->true<-cmp>", "true")] + [DataRow("<cmp 45 #= title->true<-cmp>", "true")] + [DataRow("<cmp title != 'foo'->true<-cmp>", "true")] + [DataRow("<cmp 'foo' != title->true<-cmp>", "true")] + [DataRow("<cmp 'foo' != 'bar''->true<-cmp>", "true")] + [DataRow("<!cmp title != 'foo'->false<-cmp>", "")] + [DataRow("<cmp title ~ 'A Study.*'->true<-cmp>", "true")] + [DataRow("<cmp ch count >= '99'->true<-cmp>", "true")] + [DataRow("<cmp author >= '3'->true<-cmp>", "true")] + [DataRow("<cmp author = 'Arthur Conan Doyle'->true<-cmp>", "true")] + [DataRow("<cmp tag[separator(:)slice(-2..)] :contains: 'Tag2:Tag3'->true<-cmp>", "true")] + [DataRow("<cmp tag[separator( : )slice(-2..)] = 'Tag2 : Tag3'->true<-cmp>", "true")] + // + [DataRow("<cmp tag = tag ->true<-cmp>", "")] + [DataRow("<cmp tag ≡ tag ->true<-cmp>", "true")] + [DataRow("<cmp tag == tag ->true<-cmp>", "true")] + [DataRow("<cmp tag :equals: tag ->true<-cmp>", "true")] + // + [DataRow("<cmp tag[slice(1..2)] ∌ tag[slice(3)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] !>> tag[slice(3)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] ∌ tag[slice(3)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] :not_contains: tag[slice(3)] ->true<-cmp>", "true")] + // + [DataRow("<cmp tag ∋ tag[slice(2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag >> tag[slice(2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag :contains: tag[slice(2)] ->true<-cmp>", "true")] + // + [DataRow("<cmp tag[slice(3)] ∉ tag[slice(1..2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(3)] !<< tag[slice(1..2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(3)] ∉ tag[slice(1..2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(3)] :not_in: tag[slice(1..2)] ->true<-cmp>", "true")] + // + [DataRow("<cmp tag[slice(2)] ∈ tag ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(2)] << tag ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(2)] :in: tag ->true<-cmp>", "true")] + // + [DataRow("<cmp tag[slice(1..2)] ⋂̸ tag[slice(3..4)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] &&! tag[slice(3..4)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] ⋂! tag[slice(3..4)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] :disjoint: tag[slice(3..4)] ->true<-cmp>", "true")] + // + [DataRow("<cmp tag[slice(1..2)] ⋂ tag[slice(2..4)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] && tag[slice(2..4)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] :overlaps: tag[slice(2..4)] ->true<-cmp>", "true")] + // + [DataRow("<cmp tag[slice(1..2)] ⊆ tag[slice(1..2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] ⊆ tag ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] <=< tag ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] :subset: tag ->true<-cmp>", "true")] + // + [DataRow("<cmp tag[slice(1..2)] ⊇ tag[slice(1..2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag ⊇ tag[slice(1..2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag >=> tag[slice(1..2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag :superset: tag[slice(1..2)] ->true<-cmp>", "true")] + // + [DataRow("<cmp tag[slice(1..2)] ⊂ tag[slice(1..2)] ->true<-cmp>", "")] + [DataRow("<cmp tag[slice(1..2)] ⊂ tag ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] <-< tag ->true<-cmp>", "true")] + [DataRow("<cmp tag[slice(1..2)] :proper_subset: tag ->true<-cmp>", "true")] + // + [DataRow("<cmp tag[slice(1..2)] ⊃ tag[slice(1..2)] ->true<-cmp>", "")] + [DataRow("<cmp tag ⊃ tag[slice(1..2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag >-> tag[slice(1..2)] ->true<-cmp>", "true")] + [DataRow("<cmp tag :proper_superset: tag[slice(1..2)] ->true<-cmp>", "true")] + public void Cmp_test(string template, string expected) + { + var bookDto = GetLibraryBook(); + var multiDto = new MultiConvertFileProperties + { + PartsPosition = 1, + PartsTotal = 2, + Title = bookDto.Title, + OutputFileName = "outputfile.m4b" + }; + + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + fileTemplate + .GetFilename(bookDto, multiDto, "", "", culture: null, replacements: Replacements) + .PathWithoutPrefix + .Should().Be(expected); + fileTemplate.Errors.Should().HaveCount(0); + fileTemplate.Warnings.Should().HaveCount(1); // "Should use tags. Eg: <title>" + } + [TestMethod] [DataRow("<series>", "Series A, Series B, Series C, Series D")] [DataRow("<series[]>", "Series A, Series B, Series C, Series D")] @@ -786,7 +885,7 @@ namespace TemplatesTests var bookDto = Shared.GetLibraryBook(); bookDto.Language = new CultureInfoDto(language); - var result = ""; + string result; var old = Thread.CurrentThread.CurrentCulture; var oldUi = Thread.CurrentThread.CurrentUICulture; @@ -867,7 +966,7 @@ namespace TemplatesTests var bookDto = Shared.GetLibraryBook(); bookDto.Locale = new LocaleDto(country); - var result = ""; + string result; var old = Thread.CurrentThread.CurrentCulture; var oldUi = Thread.CurrentThread.CurrentUICulture; diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index 27ac4a63..a02b95a4 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -62,7 +62,7 @@ To change how these properties are displayed, [read about custom formatters](#ta Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true. | Tag | Description | Type | -| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ----------- | +|--------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| ----------- | | \<if series-\>...\<-if series\> | Only include if part of a book series or podcast | Conditional | | \<if podcast-\>...\<-if podcast\> | Only include if part of a podcast | Conditional | | \<if bookseries-\>...\<-if bookseries\> | Only include if part of a book series | Conditional | @@ -72,6 +72,7 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w | \<is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if the PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional | | \<is PROPERTY[FORMAT][[CHECK](#checks)]-\>...\<-is\> | Only include if the formatted PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional | | \<is PROPERTY[...separator(...)...][[CHECK](#checks)]-\>...\<-is\> | Only include if the joined form of all formatted values of a list PROPERTY satisfies the CHECK | Conditional | +| \<cmp 1st-PROPERTY [[CHECK](#checks)] 2nd-PROPERTY-\>...\<-cmp\> | Only include if two given PROPERTIES satisfy the CHECK | Conditional | **†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked. @@ -79,14 +80,15 @@ For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name. -| Inverted Tag | Description | Type | -| --------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------- | -| \<!if series-\>...\<-if series\> | Only include if _not_ part of a book series or podcast | Conditional | -| \<!if podcast-\>...\<-if podcast\> | Only include if _not_ part of a podcast | Conditional | -| \<!if bookseries-\>...\<-if bookseries\> | Only include if _not_ part of a book series | Conditional | -| \<!if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is _not_ a podcast series parent | Conditional | -| \<!has PROPERTY-\>...\<-has\> | Only include if the PROPERTY _does not_ have a value (i.e. is null or empty) | Conditional | -| \<!is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if neither the whole PROPERTY nor the values of a list PROPERTY satisfies the CHECK | Conditional | +| Inverted Tag | Description | Type | +|--------------------------------------------------------|--------------------------------------------------------------------------------------------------| ----------- | +| \<!if series-\>...\<-if series\> | Only include if _not_ part of a book series or podcast | Conditional | +| \<!if podcast-\>...\<-if podcast\> | Only include if _not_ part of a podcast | Conditional | +| \<!if bookseries-\>...\<-if bookseries\> | Only include if _not_ part of a book series | Conditional | +| \<!if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is _not_ a podcast series parent | Conditional | +| \<!has PROPERTY-\>...\<-has\> | Only include if the PROPERTY _does not_ have a value (i.e. is null or empty) | Conditional | +| \<!is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if neither the whole PROPERTY nor the values of a list PROPERTY satisfies the CHECK | Conditional | +| \<!cmp 1st-PROPERTY [CHECK] 2nd-PROPERTY-\>...\<-cmp\> | Only include if two given PROPERTIES _do not_ satisfy the CHECK | Conditional | **†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked. @@ -244,28 +246,58 @@ You can specify which part of a language you are interested in. ### Checks -| Check-Pattern | Description | Example | -| ---------------- | ------------------------------------------------------------------------------- | ------------------------------------------ | -| =STRING **†** | Matches if one item is equal to STRING (case ignored) | \<is tag[=Tag1]-\> | -| !=STRING **†** | Matches if one item is not equal to STRING (case ignored) | \<is first author[!=Arthur]-\> | -| ~STRING **†** | Matches if one items is matched by the regular expression STRING (case ignored) | \<is title[~(\[XYZ\]).*\\1]-\> | -| #=NUMBER **‡** | Matches if the number value is equal to NUMBER | \<is channels[#=2]-\> | -| #!=NUMBER **‡** | Matches if the number value is not equal to NUMBER | \<is author[#!=1]-\> | -| #\>=NUMBER **‡** | Matches if the number value is greater than or equal to NUMBER | \<is bitrate[#\>=128]-\> | -| #\>NUMBER **‡** | Matches if the number value is greater than NUMBER | \<is title[#\>30]-\> | -| #\<=NUMBER **‡** | Matches if the number value is less than or equal to NUMBER | \<is first narrator[format({F})][#\<=1]-\> | -| #\<NUMBER **‡** | Matches if the number value is less than NUMBER | \<is author[#\<3]-\> | +There are two formats for checks, with slightly different syntax for specifying parameters: -**†** STRING maybe escaped with a backslash. So even square brackets could be used. If a single backslash should be part of the string, it must be doubled. +`<is PROPERTY[CHECK-with-value]-\>...\<-is\>` -**‡** NUMBER checks on lists are checking the size of the list. If the value to check is a string, its length is used. +`<cmp 1st-PROPERTY CHECK 2nd-PROPERTY-\>...\<-cmp\>` -#### More complex examples +For `CHECK-with-value`, the value (2nd parameter) is specified directly after the check operator (e.g., `=`). This may +be a number or a string with escaped characters like `\]`. Single backslashes must be doubled if they are part of the +string. -This example will truncate the title to 4 characters and check its (trimmed) value to be "the" in any case: +`1st-PROPERTY` and `2nd-PROPERTY` may be any of the properties listed in the [Properties](#properties) section, or they +may be string or number literals. Use digits only for numbers. To specify a string literal, enclose it in single or +double quotes. If the string contains a single or double quote, escape it by doubling it. For example, to specify the +string literal `O'Reilly`, you can use either `'O''Reilly'` or `"O'Reilly"`. + +| String Checks | Unicode Operator | Description | Examples | +|---------------|------------------|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------| +| = | | Matches if values are equal (case-insensitive) | \<is tag[=Tag1]-\> | +| != | | Matches if values are not equal (case-insensitive) | \<is first author[!=Arthur]-\><br>\<cmp "foo" != 'bar'-\> | +| ~ | | Matches if the first parameter matches the regular expression specified by the second parameter (case-insensitive) | \<is title[~(\[XYZ\]).*\\1]-\> | + +| Number Checks | Unicode Operator | Description | Examples | +|---------------|------------------|----------------------------------------------------------------|-----------------------------------------------------------| +| #= **†** | | Matches if the number value equals NUMBER | \<is channels[#=2]-\><br>\<cmp channels #= 1-\> | +| #!= **†** | ≠ | Matches if the number value does not equal NUMBER | \<is author[#!=1]-\> | +| #\>= **†** | ≥ | Matches if the number value is greater than or equal to NUMBER | \<is bitrate[#\>=128]-\><br>\<cmp 44100 #>= samplerate-\> | +| #\> **†** | | Matches if the number value is greater than NUMBER | \<is title[#\>30]-\><br>\<cmp minutes #\> 60-\> | +| #\<= **†** | ≤ | Matches if the number value is less than or equal to NUMBER | \<is first narrator[format({F})][#\<=1]-\> | +| #\< **†** | | Matches if the number value is less than NUMBER | \<is author[#\<3]-\> | + +| List Checks | Unicode Operator | Description | Examples | +|--------------------------|------------------|----------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------| +| == \| :equals: | ≡ | Matches if both values contain the same elements | \<cmp author[slice(-2..-1)] == narrator[slice(..2)]-\> | +| \>> \| :contains: | ∋ | Matches if the first parameter contains the second parameter | \<is author[format({L})][∋King]-\> | +| !>> \| :not_contains: | ∌ | Matches if the first parameter does not contain the second parameter | \<cmp series :not_contains: 'Harry Potter'-\> | +| << \| :in: | ∈ | Matches if the first parameter is contained in the second parameter | \<cmp first author << author[slice(2..)]-\> | +| !<< \| :not_in: | ∉ | Matches if the first parameter is not contained in the second parameter | \<cmp author[slice(1)] ∉ narrator-\> | +| && \| :overlaps: | ⋂ | Matches if both values share at least one element | \<cmp author && narrator-\> | +| &&! \| :disjoint: | ⋂̸ | Matches if both values share no elements | \<cmp tag :disjoint: series-\> | +| <=< \| :subset: | ⊆ | Matches if all elements from the first parameter are found in the second parameter | \<cmp author[slice(-3..)] <=< author[slice(..-4)]-\> | +| >=> \| :superset: | ⊇ | Matches if the first parameter contains all elements from the second parameter | \<cmp author ⊇ narrator-\> | +| <-< \| :proper_subset: | ⊂ | Matches if all elements from the first parameter are found in the second parameter, and the second has additional elements | \<cmp author[slice(..2)] :proper_subset: author-\> | +| >-> \| :proper_superset: | ⊃ | Matches if the first parameter contains all elements from the second parameter plus at least one additional element | \<cmp author ⊃ narrator-\> | + +**†** NUMBER checks on lists check the size of the list. For string values, the string length is used. + +#### More Complex Examples + +This example truncates the title to 4 characters and checks if its (trimmed) value equals "the" (case-insensitive): `<is title[4][=the]>` -Here the second to fourth tag is taken and joined with a colon. The result is then checked to be equal to "Tag2:Tag3:Tag4": +This example takes tags 2 through 4, joins them with a colon, and checks if the result equals "Tag2:Tag3:Tag4": `<is tag[separator(:)slice(2..4)][=Tag2:Tag3:Tag4]->`