Merge pull request #1735 from Jo-Be-Co/bifunctions

1714 conditional tag cmp working on two properties
This commit is contained in:
rmcrackan
2026-04-20 14:36:24 -04:00
committed by GitHub
9 changed files with 518 additions and 133 deletions

View File

@@ -26,7 +26,7 @@ internal interface IClosingPropertyTag : IPropertyTag
public delegate object? ValueProvider<in T>(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<TClass>(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive)
{
@@ -63,6 +63,17 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider));
}
/// <summary>
/// Register a conditional tag.
/// </summary>
/// <param name="templateTag"></param>
/// <param name="valueProvider1">A <see cref="ValueProvider{T}"/> to get the first condition's value. The values will be evaluated by a check specified by the tag itself.</param>
/// <param name="valueProvider2">A <see cref="ValueProvider{T}"/> to get the second condition's value. The values will be evaluated by a check specified by the tag itself.</param>
public void Add(ITemplateTag templateTag, ValueProvider<TClass> valueProvider1, ValueProvider<TClass> 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<TClass>(bool caseSensitive = true)
public override Regex NameMatcher { get; }
public Regex NameCloseMatcher { get; }
private Func<string, string?, string?, Expression> CreateConditionExpression { get; }
private Func<string, string?, string?, string?, Expression> CreateConditionExpression { get; }
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
: base(templateTag, conditionExpression)
@@ -79,7 +90,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
NameMatcher = new Regex($"^<(?<not>!)?{tagNameRe}->", options);
NameCloseMatcher = new Regex($"^<-{tagNameRe}>", options);
CreateConditionExpression = (_, _, _) => conditionExpression;
CreateConditionExpression = (_, _, _, _) => conditionExpression;
}
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider<TClass> valueProvider, ConditionEvaluator conditionEvaluator)
@@ -88,7 +99,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
// <property> 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 #
^<(?<not>!)? # tags start with a '<'. Condtionals allow an optional ! captured in <not> to negate the condition
^<(?<not>!)? # tags start with a '<'. Conditionals allow an optional ! captured in <not> 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
(?<property>.+?) # - capture the <property> non greedy so it won't end on whitespace, '[' or '-' (if match is possible)
@@ -98,47 +109,85 @@ public partial class ConditionalTagCollection<TClass>(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<TClass> valueProvider)
: base(templateTag, Expression.Constant(false))
{
// <property> 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 #
^<(?<not>!)? # tags start with a '<'. Condtionals allow an optional ! captured in <not> to negate the condition
^<(?<not>!)? # tags start with a '<'. Conditionals allow an optional ! captured in <not> 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
(?<property>.+? # - capture the <property> non greedy so it won't end on whitespace, '[' or '-' (if match is possible)
(?<!\s)) # - don't let <property> end with a whitepace. Otherwise "<tagname [foobar]->" would be matchable.
(?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall start with an operator. So match whitespace first
(?<check> # - capture inner part as <check>
(?<check_or_op> # - capture inner part as <check_or_op>
(?:\\. # - '\' 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<TClass> valueProvider, string? property,
ConditionEvaluator conditionEvaluator)
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider<TClass> valueProvider1, ValueProvider<TClass> valueProvider2)
: base(templateTag, Expression.Constant(false))
{
NameMatcher = new Regex($"""
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
^<(?<not>!)? # tags start with a '<'. Conditionals allow an optional ! captured in <not> 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
(?<property> # capture the <property>
'(?:[^']|'')*' # - 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.
(?<!\s)) # - don't let <property> end with a whitepace. Otherwise "<tagname = tag2->" would be matchable.
\s+ # Separate the following operand with whitespace
(?<check_or_op> # capture operator in <op> and <num_op> 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
(?<second_property>.+? # - capture the <second_property> non greedy so it won't end on whitespace
(?<!\s)) # - don't let <second_property> end with a whitepace. Otherwise "<tagname tag1 = ->" 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<TClass>(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<object> 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<object> 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<object> 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 <val> 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<char> opString)
{
Func<int?, int?, CultureInfo?, bool> 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<object> 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<char> opString)
{
var checklist = opString switch
{
"∋" or ">>" or ":contains:" => Swap<IEnumerable<string>>(IsSubset),
"⊇" or ">=>" or ":superset:" => Swap<IEnumerable<string>>(IsSubset),
"⊃" or ">->" or ":proper_superset:" => Swap<IEnumerable<string>>(IsProperSubset),
"∌" or "!>>" or "∌" or ":not_contains:" => Invert(Swap<IEnumerable<string>>(IsSubset)),
"∈" or "<<" or ":in:" => IsSubset,
"⊆" or "<=<" or ":subset:" => IsSubset,
"⊂" or "<-<" or ":proper_subset:" => IsProperSubset,
"∉" or "!<<" or "∉" or ":not_in:" => Invert<IEnumerable<string>>(IsSubset),
"⋂" or "&&" or ":overlaps:" => Overlaps,
"⋂̸" or "&&!" or "⋂!" or ":disjoint:" => Invert<IEnumerable<string>>(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<string> ToEnumerable(object value)
{
return value switch
{
IEnumerable<string> e => e,
IEnumerable<object> e => e.Select(o => o.ToString() ?? ""),
string s => [s],
_ => [value.ToString() ?? ""]
};
}
private static ConditionEvaluator GetPredicateForStringOp(string exactName, ReadOnlySpan<char> 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<object> e1, _) => e1.Any(l => checkItem(l, v2, culture)),
(_, IEnumerable<object> e2) => e2.Any(r => checkItem(v1, r, culture)),
_ => checkItem(v1, v2, culture)
};
}
private static bool Overlaps(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
{
var comparer = GetStringComparer(culture);
return e1.Any(l => e2.Contains(l, comparer));
}
private static bool IsSubset(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
{
var comparer = GetStringComparer(culture);
return e1.All(l => e2.Contains(l, comparer));
}
private static bool IsProperSubset(IEnumerable<string> e1, IEnumerable<string> 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<T, T, CultureInfo?, bool> Invert<T>(Func<T, T, CultureInfo?, bool> condition) => (v1, v2, culture) => !condition(v1, v2, culture);
private static Func<T, T, CultureInfo?, bool> Swap<T>(Func<T, T, CultureInfo?, bool> condition) => (v1, v2, culture) => condition(v2, v1, culture);
private static Func<object, object, CultureInfo?, bool> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="exactName">The full tag string for context in error messages</param>
/// <param name="pattern">The regex pattern to match</param>
/// <returns>check function to validate an object</returns>
/// <exception cref="InvalidOperationException">Thrown when regex parsing fails or when regex matching times out, indicating faulty user input</exception>
private static Func<object, CultureInfo?, bool> GetRegExpCheck(string exactName, string pattern)
private static Func<object, object, CultureInfo?, bool> 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<TClass>(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<TClass>(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<TClass>(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 #
^(?<op>(?<num_op> # anchor at start of linecapture operator in <op> and <num_op> 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
(?<val>(?(num_op) # capture value in <val>
(?:\\?\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 #
^(?>(?<op>(?<list_op> # anchor at start of line. capture operator in <op>, <list_op> and <num_op> 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)
) | (?<num_op>
\#!?= | ≠ | ≠ # - numerical operators: #= #!= ≠
| \#[<>]=? # - numerical operators: #<= #>= #< #>
| [<>]=? | | # - numerical operators: <= >= < >
) | [=!]?~ | !=? | =? # - string comparison operators including ~ for regexp, = and !=. No operator is like =
)) \s*? # ignore space between operator and value
(?<val>(?(num_op) # capture value in <val>
(?:\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();
}

View File

@@ -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;

View File

@@ -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;
/// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary>
public abstract class TagCollection : IEnumerable<ITemplateTag>
{
protected static readonly ConcurrentDictionary<string, Regex> RegexCache = new();
/// <summary>The <see cref="ITemplateTag"/>s registered with this <see cref="TagCollection"/> </summary>
public IEnumerator<ITemplateTag> GetEnumerator() => PropertyTags.Select(p => p.TemplateTag).GetEnumerator();
@@ -79,7 +82,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
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);
}

View File

@@ -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>", "<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>", "<has PROPERTY->...<-has>");
public static TemplateTags Is { get; } = new("is", "Only include if PROPERTY has a value satisfying the check (i.e. string comparison)", "<is -><-is>", "<is PROPERTY->...<-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>",
"<cmp PROPERTY OP PROPERTY->...<-cmp>");
}

View File

@@ -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<TagCollection> 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: <ch#> or <ch# 0>";
@@ -338,6 +339,7 @@ public abstract class Templates
private static readonly ConditionalTagCollection<CombinedDto> 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<PropertyTagCollection<LibraryBookDto>>())
{
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*(?<quote>['""])(?<value>(?:(?<double>\k<quote>{2})|.)*)\k<quote>\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

View File

@@ -27,6 +27,33 @@ public class ConditionalTagCollectionTests
private static object? TryGetValue(ITemplateTag _, TestObject obj, string condition, CultureInfo? culture)
=> obj.Value;
/// <summary>
/// Test that invalid regex patterns throw InvalidOperationException during evaluation.
/// Tests include malformed patterns and catastrophic backtracking scenarios.
/// </summary>
[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 = $"<testcond foobar[{check}]->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
}
}
/// <summary>
/// Test that invalid regex patterns throw InvalidOperationException during evaluation.
/// Tests include malformed patterns and catastrophic backtracking scenarios.

View File

@@ -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()
{

View File

@@ -545,15 +545,19 @@ namespace TemplatesTests
[DataRow("<is ch count[<=100]->true<-is>", "true")]
[DataRow("<is ch count[<100]->true<-is>", "true")]
[DataRow("<is ch count[=2]->true<-is>", "true")]
[DataRow("<is author[>=3]->true<-is>", "")]
[DataRow("<is author[>=2]->true<-is>", "true")]
[DataRow("<is author[#=2]->true<-is>", "true")]
[DataRow("<is author[=Arthur Conan Doyle]->true<-is>", "true")]
[DataRow("<is author[format({L})][=Doyle]->true<-is>", "true")]
[DataRow("<is author[format({L})][=]->true<-is>", "")]
[DataRow("<is author[format({L})][>>]->true<-is>", "")]
[DataRow("<is author[format({M})][=]->true<-is>", "true")]
[DataRow("<is author[format({M})][>>]->true<-is>", "true")]
[DataRow("<!is author[format({L})][=Doyle]->false<-is>", "")]
[DataRow("<is author[format({L})][!=Doyle]->true<-is>", "true")]
[DataRow("<!is author[format({L})][!=Doyle]->false<-is>", "")]
[DataRow("<is author[format({L})separator(:)][=Doyle:Fry]->true<-is>", "true")]
[DataRow("<is author[>=3]->true<-is>", "")]
[DataRow(@"<is author[slice(99)][~.\*]->true<-is>", "")]
[DataRow("<is author[slice(99)separator(:)][~.*]->true<-is>", "")]
[DataRow("<is author[slice(-9)separator(:)][~.*]->true<-is>", "")]
@@ -561,22 +565,30 @@ namespace TemplatesTests
[DataRow("<is author[slice(-1..1)separator(:)][~.*]->true<-is>", "")]
[DataRow("<is author[slice(-1..-2)separator(:)][~.*]->true<-is>", "")]
[DataRow("<is author[=Sherlock]->true<-is>", "")]
[DataRow("<is author[=~Sherlock]->true<-is>", "")]
[DataRow("<!is author[=Sherlock]->false<-is>", "false")]
[DataRow("<!is author[=~Sherlock]->false<-is>", "false")]
[DataRow("<is author[!=Sherlock]->true<-is>", "true")]
[DataRow("<is author[!~Sherlock]->true<-is>", "true")]
[DataRow("<!is author[!=Sherlock]->false<-is>", "")]
[DataRow("<!is author[!~Sherlock]->false<-is>", "")]
[DataRow("<is tag[=Tag1]->true<-is>", "true")]
[DataRow("<is tag[separator(:)slice(-2..)][=Tag2:Tag3]->true<-is>", "true")]
[DataRow("<is audible subtitle[3][=an]->false<-is>", "")]
[DataRow("<is audible subtitle[3][=an]->true<-is>", "")]
[DataRow("<is audible subtitle[3][=an ]->true<-is>", "true")]
[DataRow(@"<is audible subtitle[3][=an\ ]->true<-is>", "true")]
[DataRow("<is audible subtitle[3][= an]->false<-is>", "")]
[DataRow("<is audible subtitle[3][= an ]->false<-is>", "")]
[DataRow(@"<is audible subtitle[3][= an\ ]->false<-is>", "")]
[DataRow(@"<is audible subtitle[3][=\ an\ ]->false<-is>", "")]
[DataRow("<is audible subtitle[3][ =an]->false<-is>", "")]
[DataRow("<is audible subtitle[3][= an]->true<-is>", "")]
[DataRow("<is audible subtitle[3][= an ]->true<-is>", "")]
[DataRow(@"<is audible subtitle[3][= an\ ]->true<-is>", "")]
[DataRow(@"<is audible subtitle[3][=\ an\ ]->true<-is>", "")]
[DataRow("<is audible subtitle[3][ =an]->true<-is>", "")]
[DataRow("<is audible subtitle[3][ =an ]->true<-is>", "true")]
[DataRow(@"<is audible subtitle[3][ =an\ ]->true<-is>", "true")]
[DataRow(@"<is minutes[>42]->true<-is>", "true")]
[DataRow("<is minutes[>42]->true<-is>", "true")]
[DataRow("<is dateadded[>9000]->true<-is>", "true")]
[DataRow("<is dateadded[>90000]->true<-is>", "")]
[DataRow("<is locale[>42]->true<-is>", "")]
[DataRow("<is language[>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: <title>"
}
[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;

View File

@@ -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]->`