mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-05-09 08:04:13 -04:00
Merge pull request #1735 from Jo-Be-Co/bifunctions
1714 conditional tag cmp working on two properties
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]->`
|
||||
|
||||
Reference in New Issue
Block a user