From a010da525117536dca09258f705bd62616d3fc03 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Mon, 13 Apr 2026 01:39:16 +0200 Subject: [PATCH 01/11] #1714 Convert the evaluation of the 'is' tag into two-parameter logic --- .../ConditionalTagCollection[TClass].cs | 182 ++++++++++-------- .../Templates/Templates.cs | 2 +- .../FileNamingTemplateTests.cs | 2 +- .../TemplatesTests.cs | 20 +- 4 files changed, 116 insertions(+), 90 deletions(-) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 3e3bdb5f..68572693 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -26,7 +26,7 @@ internal interface IClosingPropertyTag : IPropertyTag public delegate object? ValueProvider(ITemplateTag templateTag, T value, string condition, CultureInfo? culture); -public delegate bool ConditionEvaluator(object? value, CultureInfo? culture); +public delegate bool ConditionEvaluator(object? value1, object? value2, CultureInfo? culture); public partial class ConditionalTagCollection(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive) { @@ -99,27 +99,30 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); CreateConditionExpression = (_, property, _) - => ConditionEvaluatorCall(templateTag, parameter, valueProvider, property, conditionEvaluator); + => ConditionEvaluatorCall(conditionEvaluator, + ValueProviderCall(templateTag, parameter, valueProvider, property), + Expression.Constant(null)); } public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider valueProvider) : base(templateTag, Expression.Constant(false)) { // needs to match on at least one character, which is not a space. - // though we will capture the group named `check` enclosed in [] at the end of the tag, the property itself might also have a [] part for formatting purposes + // though we will capture the group named `check_or_op` enclosed in [] at the end of the tag, the property itself might also have a [] part for formatting purposes NameMatcher = new Regex($""" (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # ^<(?!)? # tags start with a '<'. Condtionals allow an optional ! captured in to negate the condition {TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#' (?:\s+ # the following part is optional. If present it starts with some whitespace - (?.+? # - capture the non greedy so it won't end on whitespace, '[' or '-' (if match is possible) - (? end with a whitepace. Otherwise "" would be matchable. + (?(?: # capture the + [^<=~>!] # - match any character with some exclusions that should only be used in operands + ) +? (? end with a whitepace. Otherwise "" would be matchable. (?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall start with an operator. So match whitespace first - (? # - capture inner part as + (? # - capture inner part as (?:\\. # - '\' escapes allways 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); @@ -127,18 +130,20 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) CreateConditionExpression = (exactName, property, checkString) => { - var conditionEvaluator = GetPredicate(exactName, checkString); - return ConditionEvaluatorCall(templateTag, parameter, valueProvider, property, conditionEvaluator); + var (value, conditionEvaluator) = GetPredicate(exactName, checkString); + return ConditionEvaluatorCall(conditionEvaluator, + ValueProviderCall(templateTag, parameter, valueProvider, property), + BuildArgument(value, conditionEvaluator.Method.GetParameters()[1].ParameterType)); }; } - private static MethodCallExpression ConditionEvaluatorCall(ITemplateTag templateTag, ParameterExpression parameter, ValueProvider valueProvider, string? property, - ConditionEvaluator conditionEvaluator) + 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,58 +158,75 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) CultureParameter); } - private static ConditionEvaluator GetPredicate(string exactName, string? checkString) + private static Expression BuildArgument(object value, Type targetType) + { + var constant = Expression.Constant(value, value.GetType()); + return constant.Type == targetType ? constant : Expression.Convert(constant, targetType); + } + + private static (Object, ConditionEvaluator) GetPredicate(string exactName, string? checkString) { if (checkString == null) - return (v, _) => v switch + return ("", (v1, v2, _) => v1 switch { null => false, IEnumerable e => e.Any(), - _ => !string.IsNullOrWhiteSpace(v.ToString()) - }; + _ => !string.IsNullOrWhiteSpace(v1.ToString()) + }); var match = CheckRegex().Match(checkString); - 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 + if (match.Groups["num_op"].Success) { - "=" 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 + Func checkInt = match.Groups["op"].ValueSpan switch { - null => false, - IEnumerable e => checkItem(e.Count(), culture), - string s => checkItem(s.Length, culture), - TimeSpan ts => checkItem(ts.TotalMinutes, culture), - _ => checkItem(v, culture) - } - : (v, culture) => v switch - { - null => false, - IEnumerable e => e.Any(o => checkItem(o, culture)), - _ => checkItem(v, culture) + "#=" => (v1, v2, _) => v1 == v2, + "#!=" => (v1, v2, _) => v1 != v2, + "#>=" or ">=" => (v1, v2, _) => v1 >= v2, + "#>" or ">" => (v1, v2, _) => v1 > v2, + "#<=" 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 (Convert.ToInt32(valStr), + (v1, v2, culture) => v1 is not null && v2 is not null && checkInt(ToIntObject(v1), ToIntObject(v2), culture)); + } - int? VAsInt(object v) => v is int iv ? iv : int.TryParse(v.ToString(), out var parsed) ? parsed : null; + Func checkItem = Unescape(match.Groups["op"]) 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 (valStr, + (v1, v2, culture) => (v1, v2) switch + { + (null, _) => false, + (IEnumerable e1, _) => e1.Any(l => checkItem(l, v2, culture)), + _ => checkItem(v1, v2, culture) + }); } - private static int VComparedToStr(object? v, CultureInfo? culture, string valStr) + private static int ToIntObject(object value) { - culture ??= CultureInfo.CurrentCulture; - return culture.CompareInfo.Compare(v?.ToString(), valStr, CompareOptions.IgnoreCase); + return value switch + { + IEnumerable e => e.Count(), + TimeSpan ts => (int)ts.TotalMinutes, + string s => s.Length, + int i => i, + _ => throw new ArgumentOutOfRangeException() + }; + } + + private static Func Invert(Func condition) => (v1, v2, culture) => !condition(v1, v2, culture); + + private static Func GetStringEqCheck() + { + return (v1, v2, culture) => (culture ?? CultureInfo.CurrentCulture).CompareInfo.Compare(v1?.ToString(), v2.ToString(), CompareOptions.IgnoreCase) == 0; } /// @@ -216,25 +238,25 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) /// The regex pattern to match /// check function to validate an object /// Thrown when regex parsing fails or when regex matching times out, indicating faulty user input - private static Func GetRegExpCheck(string exactName, string pattern) + private static Func GetRegExpCheck(string exactName) { - Regex regex; - try - { - // Compile regex with timeout to prevent catastrophic backtracking - regex = new Regex(pattern, - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, - RegexpCheckTimeout); - } - catch (ArgumentException ex) - { - // If regex compilation fails, throw as faulty user input - var errorMessage = BuildErrorMessage(exactName, pattern, "Invalid regular expression pattern. Correct the pattern and escaping or remove that condition"); - throw new InvalidOperationException(errorMessage, ex); - } - - return (v, _) => + return (v1, v2, _) => { + Regex regex; + var pattern = v2.ToString() ?? ""; + 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); + } try { // CultureInfo parameter is intentionally ignored (discarded with _). @@ -250,7 +272,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) // - Lithuanian locale: 'i' after 'ž' has an accent that affects sorting/matching. // // For naming templates, culture-invariant is the safer default. - return regex.IsMatch(v.ToString() ?? ""); + return regex.IsMatch(v1.ToString() ?? ""); } catch (RegexMatchTimeoutException ex) { @@ -300,22 +322,22 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) var getBool = CreateConditionExpression( exactName, matchData.GetValueOrDefault("property")?.Value, - matchData.GetValueOrDefault("check")?.ValueOrNull()); + matchData.GetValueOrDefault("check_or_op")?.ValueOrNull()); return matchData["not"].Success ? Expression.Not(getBool) : getBool; } [GeneratedRegex(""" - (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # - ^(?(? # anchor at start of linecapture operator in and with every char escapable - \\?\#(?:\\?!)?\\?= # - numerical operators: #= #!= - | \\?\#\\?[<>](?:\\?=)? # - numerical operators: #>= #<= #> #< - | \\?[<>](?:\\?=)? # - numerical operators: >= <= > < - ) | \\?~|\\?!(?:\\?=)?|(?:\\?=)? # - string comparison operators including ~ for regexp, = and !=. No operator is like = - ) \s*? # ignore space between operator and value - (?(?(num_op) # capture value in - (?:\\?\d)+ # - numerical operators have to be followed by a number - | (?:\\.|[^\\])* ) # - string for comparison. May be empty. Capturing also all whitespace up to the end as this must have been escaped. - )$ # match to the end + (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # + ^(?(? # anchor at start of linecapture operator in and with every char escapable + \#!?= # - numerical operators: #= #!= + | \#[<>]=? # - numerical operators: #>= #<= #> #< + | [<>]=? # - numerical operators: >= <= > < + ) | [=!]?~ | !=? | =? # - string comparison operators including ~ for regexp, = and !=. No operator is like = + ) \s*? # ignore space between operator and value + (?(?(num_op) # capture value in + (?:\d)* # - numerical operators have to be followed by a number + | (?:\\.|[^\\])* ) # - string for comparison. May be empty. Capturing also all whitespace up to the end as this must have been escaped. + )$ # match to the end """)] private static partial Regex CheckRegex(); } diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 186dcfb4..4f59b98f 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -367,7 +367,7 @@ public abstract class Templates return null; } - private static bool HasValue(object? value, CultureInfo? culture) + private static bool HasValue(object? value, object? _, CultureInfo? culture) { bool CheckItem(object o, CultureInfo? _) => !string.IsNullOrWhiteSpace(o.ToString()); return value switch diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs index e4d9d2a0..ba796f7e 100644 --- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs +++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs @@ -107,7 +107,7 @@ public class GetPortionFilename private static object? TryGetValue(ITemplateTag templateTag, PropertyClass3 referenceType, string condition, CultureInfo? culture) => props3.TryGetObject(condition, referenceType, culture, out var value) ? value : null; - private static bool HasValue(object? value, CultureInfo? culture) => value is not null && !string.IsNullOrWhiteSpace(value.ToString()); + private static bool HasValue(object? value, object? value2, CultureInfo? culture) => value is not null && !string.IsNullOrWhiteSpace(value.ToString()); private readonly PropertyClass1 _propertyClass1 = new() { diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index ee97d4bb..1e532c38 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -544,6 +544,7 @@ namespace TemplatesTests [DataRow("true<-is>", "true")] [DataRow("true<-is>", "true")] [DataRow("true<-is>", "true")] + [DataRow("=3]->true<-is>", "")] [DataRow("=2]->true<-is>", "true")] [DataRow("true<-is>", "true")] [DataRow("true<-is>", "true")] @@ -552,7 +553,6 @@ namespace TemplatesTests [DataRow("true<-is>", "true")] [DataRow("false<-is>", "")] [DataRow("true<-is>", "true")] - [DataRow("=3]->true<-is>", "")] [DataRow(@"true<-is>", "")] [DataRow("true<-is>", "")] [DataRow("true<-is>", "")] @@ -560,22 +560,26 @@ namespace TemplatesTests [DataRow("true<-is>", "")] [DataRow("true<-is>", "")] [DataRow("true<-is>", "")] + [DataRow("true<-is>", "")] [DataRow("false<-is>", "false")] + [DataRow("false<-is>", "false")] [DataRow("true<-is>", "true")] + [DataRow("true<-is>", "true")] [DataRow("false<-is>", "")] + [DataRow("false<-is>", "")] [DataRow("true<-is>", "true")] [DataRow("true<-is>", "true")] - [DataRow("false<-is>", "")] + [DataRow("true<-is>", "")] [DataRow("true<-is>", "true")] [DataRow(@"true<-is>", "true")] - [DataRow("false<-is>", "")] - [DataRow("false<-is>", "")] - [DataRow(@"false<-is>", "")] - [DataRow(@"false<-is>", "")] - [DataRow("false<-is>", "")] + [DataRow("true<-is>", "")] + [DataRow("true<-is>", "")] + [DataRow(@"true<-is>", "")] + [DataRow(@"true<-is>", "")] + [DataRow("true<-is>", "")] [DataRow("true<-is>", "true")] [DataRow(@"true<-is>", "true")] - [DataRow(@"42]->true<-is>", "true")] + [DataRow("42]->true<-is>", "true")] public void HasValue_test(string template, string expected) { var bookDto = GetLibraryBook(); From 810ea907702408362e2e2b5103bef441491dfdd9 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Tue, 14 Apr 2026 00:30:34 +0200 Subject: [PATCH 02/11] introduce cmp tag as check with two operands --- .../ConditionalTagCollection[TClass].cs | 78 ++++++++++++++++--- .../Templates/TemplateTags.cs | 3 + .../Templates/Templates.cs | 24 +++++- .../TemplatesTests.cs | 66 ++++++++++++++++ 4 files changed, 158 insertions(+), 13 deletions(-) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 68572693..04ad19a9 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -63,6 +63,17 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider)); } + /// + /// Register a conditional tag. + /// + /// + /// A to get the first condition's value. The values will be evaluated by a check specified by the tag itself. + /// A to get the second condition's value. The values will be evaluated by a check specified by the tag itself. + public void Add(ITemplateTag templateTag, ValueProvider valueProvider1, ValueProvider valueProvider2) + { + AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider1, valueProvider2)); + } + private partial class ConditionalTag : TagBase, IClosingPropertyTag { private static readonly TimeSpan RegexpCheckTimeout = TimeSpan.FromMilliseconds(100); @@ -70,7 +81,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) public override Regex NameMatcher { get; } public Regex NameCloseMatcher { get; } - private Func CreateConditionExpression { get; } + private Func CreateConditionExpression { get; } public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression) : base(templateTag, conditionExpression) @@ -79,7 +90,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) NameMatcher = new Regex($"^<(?!)?{tagNameRe}->", options); NameCloseMatcher = new Regex($"^<-{tagNameRe}>", options); - CreateConditionExpression = (_, _, _) => conditionExpression; + CreateConditionExpression = (_, _, _, _) => conditionExpression; } public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider valueProvider, ConditionEvaluator conditionEvaluator) @@ -98,7 +109,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) , options); NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); - CreateConditionExpression = (_, property, _) + CreateConditionExpression = (_, property, _, _) => ConditionEvaluatorCall(conditionEvaluator, ValueProviderCall(templateTag, parameter, valueProvider, property), Expression.Constant(null)); @@ -128,7 +139,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) , options); NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); - CreateConditionExpression = (exactName, property, checkString) => + CreateConditionExpression = (exactName, property, checkString, _) => { var (value, conditionEvaluator) = GetPredicate(exactName, checkString); return ConditionEvaluatorCall(conditionEvaluator, @@ -137,6 +148,40 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) }; } + public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider valueProvider1, ValueProvider valueProvider2) + : base(templateTag, Expression.Constant(false)) + { + NameMatcher = new Regex($""" + (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # + ^<(?!)? # tags start with a '<'. Condtionals allow an optional ! captured in to negate the condition + {TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#' + \s+ # Separate the following with whitespace + (?(?: # capture the + '(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character + | "(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character + | [^:\#!≡=≠~<>≤≥&∉∌∈∌⋂⊆⊇⊂⊃-] # - match any character with some exclusions that should only be used in operands + ) +? (? end with a whitepace. Otherwise "" would be matchable. + \s+ # Separate the following operand with whitespace + (? # capture operator in and with every char escapable + [\#!≡=≠~<>≤≥&∉∌∈∌⋂⊆⊇⊂⊃-]+ # allow a wide range of operators, all non alphanumeric + | :[a-z_]+: # allow :named: operators for readability, e.g. :contains: + ) \s+ # ignore space between operator and second property + (?.+? # - capture the non greedy so it won't end on whitespace + (? end with a whitepace. Otherwise "" would be matchable. + \s*-> # Opening tags end with '->' and closing tags begin with '<-', so both sides visually point toward each other + """ + , options); + NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); + + CreateConditionExpression = (exactName, property1, checkString, property2) => + { + var (_, conditionEvaluator) = GetPredicate(exactName, checkString); + return ConditionEvaluatorCall(conditionEvaluator, + ValueProviderCall(templateTag, parameter, valueProvider1, property1), + ValueProviderCall(templateTag, parameter, valueProvider2, property2)); + }; + } + private static MethodCallExpression ConditionEvaluatorCall(ConditionEvaluator conditionEvaluator, Expression valueExpression1, Expression valueExpression2) { return Expression.Call( @@ -166,7 +211,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) private static (Object, ConditionEvaluator) GetPredicate(string exactName, string? checkString) { - if (checkString == null) + if (checkString is null) return ("", (v1, v2, _) => v1 switch { null => false, @@ -189,11 +234,12 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) "#<" or "<" => (v1, v2, _) => v1 < v2, _ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values }; - return (Convert.ToInt32(valStr), + int.TryParse(valStr, out var valInt); + return (valInt, (v1, v2, culture) => v1 is not null && v2 is not null && checkInt(ToIntObject(v1), ToIntObject(v2), culture)); } - Func checkItem = Unescape(match.Groups["op"]) switch + Func checkItem = match.Groups["op"].Value switch { "=" or "" => GetStringEqCheck(), "!=" or "!" => Invert(GetStringEqCheck()), @@ -205,7 +251,9 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) (v1, v2, culture) => (v1, v2) switch { (null, _) => false, + (_, null) => false, (IEnumerable e1, _) => e1.Any(l => checkItem(l, v2, culture)), + (_, IEnumerable e2) => e2.Any(r => checkItem(v1, r, culture)), _ => checkItem(v1, v2, culture) }); } @@ -222,13 +270,18 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) }; } - private static Func Invert(Func condition) => (v1, v2, culture) => !condition(v1, v2, culture); + private static Func Invert(Func condition) => (v1, v2, culture) => !condition(v1, v2, culture); private static Func GetStringEqCheck() { - return (v1, v2, culture) => (culture ?? CultureInfo.CurrentCulture).CompareInfo.Compare(v1?.ToString(), v2.ToString(), CompareOptions.IgnoreCase) == 0; + return (v1, v2, culture) => GetStringComparer(culture).Equals(v1?.ToString(), v2.ToString()); } + private static StringComparer GetStringComparer(CultureInfo? culture) + { + return StringComparer.Create(culture ?? CultureInfo.CurrentCulture, ignoreCase: true); + } + /// /// Build a regular expression check. Uses culture-invariant matching for thread-safety and consistency. /// Applies a timeout to prevent regex patterns from causing excessive backtracking and blocking. @@ -322,7 +375,8 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) var getBool = CreateConditionExpression( exactName, matchData.GetValueOrDefault("property")?.Value, - matchData.GetValueOrDefault("check_or_op")?.ValueOrNull()); + matchData.GetValueOrDefault("check_or_op")?.ValueOrNull(), + matchData.GetValueOrDefault("second_property")?.ValueOrNull()); return matchData["not"].Success ? Expression.Not(getBool) : getBool; } @@ -330,8 +384,8 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # ^(?(? # anchor at start of linecapture operator in and with every char escapable \#!?= # - numerical operators: #= #!= - | \#[<>]=? # - numerical operators: #>= #<= #> #< - | [<>]=? # - numerical operators: >= <= > < + | \#[<>]=? # - numerical operators: #<= #>= #< #> + | [<>]=? # - numerical operators: <= >= < > ) | [=!]?~ | !=? | =? # - string comparison operators including ~ for regexp, = and !=. No operator is like = ) \s*? # ignore space between operator and value (?(?(num_op) # capture value in diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index 5c6cdf68..e1a8fb03 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -66,4 +66,7 @@ public sealed class TemplateTags : ITemplateTag public static TemplateTags IfAbridged { get; } = new("if abridged", "Only include if abridged", "<-if abridged>", "...<-if abridged>"); public static TemplateTags Has { get; } = new("has", "Only include if PROPERTY has a value (i.e. not null or empty)", "<-has>", "...<-has>"); public static TemplateTags Is { get; } = new("is", "Only include if PROPERTY has a value satisfying the check (i.e. string comparison)", "<-is>", "...<-is>"); + + public static TemplateTags Cmp { get; } = new("cmp", "Only include if first PROPERTY has a value satisfying the check (i.e. string comparison) against the second PROPERTY", "<-cmp>", + "...<-cmp>"); } diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 4f59b98f..a0807989 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using AaxDecrypter; using Dinah.Core; using FileManager; @@ -19,7 +20,7 @@ public interface ITemplate static abstract IEnumerable TagCollections { get; } } -public abstract class Templates +public abstract partial class Templates { public const string ErrorFullPathIsInvalid = @"No colons or full paths allowed. Eg: should not start with C:\"; public const string WarningNoChapterNumberTag = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: or "; @@ -336,6 +337,7 @@ public abstract class Templates private static readonly ConditionalTagCollection combinedConditionalTags = new() { { TemplateTags.Is, TryGetValue }, + { TemplateTags.Cmp, TryGetValue, TryGetValue }, { TemplateTags.Has, TryGetValue, HasValue } }; @@ -349,6 +351,23 @@ public abstract class Templates private static object? TryGetValue(ITemplateTag _, CombinedDto dtos, string property, CultureInfo? culture) { + // check for string literal first + if (StringValueRegex().TryMatch(property, out var stringValue)) + { + // inside the quotes, doubled quotes are used to represent literal quotes. So replace them back to single quotes if there are any. + // this match helps to determine which quote type is being used so that the correct one can be replaced. + var doubleQuote = stringValue.Groups["double"]; + return doubleQuote.Success + ? stringValue.Groups["value"].Value.Replace(doubleQuote.Value, stringValue.Groups["quote"].Value) + : stringValue.Groups["value"].Value; + } + // then check for int literal + if (int.TryParse(property, out var intVal)) + { + return intVal; + } + + // then check for property tags and retrieve their value foreach (var c in allPropertyTags.OfType>()) { if (c.TryGetObject(property, dtos.LibraryBook, culture, out var value)) @@ -367,6 +386,9 @@ public abstract class Templates return null; } + [GeneratedRegex(@"^\s*(?['""])(?(?:(?\k{2})|.)*)\k\s*$")] + private static partial Regex StringValueRegex(); + private static bool HasValue(object? value, object? _, CultureInfo? culture) { bool CheckItem(object o, CultureInfo? _) => !string.IsNullOrWhiteSpace(o.ToString()); diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 1e532c38..95853e31 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -600,6 +600,72 @@ namespace TemplatesTests fileTemplate.Warnings.Should().HaveCount(1); // "Should use tags. Eg: " } + [TestMethod] + [DataRow(@"<cmp codec = 'aac[lc]\mp3'->true<-cmp>", "true")] + [DataRow(@"<cmp codec = 'aac[lc]\mp4'->true<-cmp>", "")] + [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[U] = 'A STUDY IN SCARLET: AN AUDIBLE ORIGINAL DRAMA'->true<-cmp>", "true")] + [DataRow("<cmp title #= '45'->true<-cmp>", "")] + [DataRow("<cmp title #= 45->true<-cmp>", "true")] + [DataRow("<cmp title != 'foo'->true<-cmp>", "true")] + [DataRow("<!cmp title != 'foo'->false<-cmp>", "")] + [DataRow("<cmp title ~ 'A Study.*'->true<-cmp>", "true")] + [DataRow("<cmp title = 'foo'->true<-cmp>", "")] + [DataRow("<cmp ch count >= '99'->true<-cmp>", "true")] + [DataRow("<cmp ch count >= 1->true<-cmp>", "true")] + [DataRow("<cmp ch count > 1->true<-cmp>", "true")] + [DataRow("<cmp ch count <= 100->true<-cmp>", "true")] + [DataRow("<cmp ch count < 100->true<-cmp>", "true")] + [DataRow("<cmp ch count = 2->true<-cmp>", "true")] + [DataRow("<cmp author >= '3'->true<-cmp>", "true")] + [DataRow("<cmp author >= 3->true<-cmp>", "")] + [DataRow("<cmp author >= 2->true<-cmp>", "true")] + [DataRow("<cmp author #= 2->true<-cmp>", "true")] + [DataRow("<cmp author = 'Arthur Conan Doyle'->true<-cmp>", "true")] + [DataRow("<cmp author[format({L})] = 'Doyle'->true<-cmp>", "true")] + [DataRow("<!cmp author[format({L})] = 'Doyle'->false<-cmp>", "")] + [DataRow("<cmp author[format({L})] != 'Doyle'->true<-cmp>", "true")] + [DataRow("<!cmp author[format({L})] != 'Doyle'->false<-cmp>", "")] + [DataRow("<cmp author[format({L})separator(:)] = 'Doyle:Fry'->true<-cmp>", "true")] + [DataRow(@"<cmp author[slice(99)] =~ '.\*'->true<-cmp>", "")] + [DataRow("<cmp author[slice(99)separator(:)] =~ '.*'->true<-cmp>", "")] + [DataRow("<cmp author[slice(-9)separator(:)] =~ '.*'->true<-cmp>", "")] + [DataRow("<cmp author[slice(2..1)separator(:)] ~ '.*'->true<-cmp>", "")] + [DataRow("<cmp author[slice(-1..1)separator(:)] ~ '.*'->true<-cmp>", "")] + [DataRow("<cmp author[slice(-1..-2)separator(:)] ~ '.*'->true<-cmp>", "")] + [DataRow("<cmp author = 'Sherlock'->true<-cmp>", "")] + [DataRow("<!cmp author = 'Sherlock'->false<-cmp>", "false")] + [DataRow("<cmp author != 'Sherlock'->true<-cmp>", "true")] + [DataRow("<!cmp author != 'Sherlock'->false<-cmp>", "")] + [DataRow("<cmp tag = 'Tag1'->true<-cmp>", "true")] + [DataRow("<cmp tag[separator(:)slice(-2..)] = 'Tag2:Tag3'->true<-cmp>", "true")] + [DataRow("<cmp audible subtitle[3] = 'an'->true<-cmp>", "")] + [DataRow("<cmp audible subtitle[3] = 'an '->true<-cmp>", "true")] + [DataRow("<cmp audible subtitle[3] = ' an'->true<-cmp>", "")] + [DataRow("<cmp audible subtitle[3] = ' an '->true<-cmp>", "")] + [DataRow("<cmp minutes > '42'->true<-cmp>", "true")] + [DataRow("<cmp minutes > 42->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")] From 56c15d3108927faf6271e2d2dc019537664a1725 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Tue, 14 Apr 2026 00:38:41 +0200 Subject: [PATCH 03/11] add checks on lists --- .../ConditionalTagCollection[TClass].cs | 70 ++++++++++++++++++- .../TemplatesTests.cs | 6 ++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 04ad19a9..18ffbac6 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -239,6 +239,32 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) (v1, v2, culture) => v1 is not null && v2 is not null && checkInt(ToIntObject(v1), ToIntObject(v2), culture)); } + if (match.Groups["list_op"].Success) + { + var stringEqCheck = GetStringEqCheck(); + Func<IEnumerable<string>, IEnumerable<string>, CultureInfo?, bool> checklist = match.Groups["op"].ValueSpan switch + { + ">>" or ":contains:" => Swap<IEnumerable<string>>(IsSubset), + ">=>" or ":superset:" => Swap<IEnumerable<string>>(IsSubset), + ">->" or ":proper_superset:" => Swap<IEnumerable<string>>(IsProperSubset), + "!>>" or ":not_contains:" => Invert(Swap<IEnumerable<string>>(IsSubset)), + "<<" or ":in:" => IsSubset, + "<=<" or ":subset:" => IsSubset, + "<-<" or ":proper_subset:" => IsProperSubset, + "!<<" or ":not_in:" => Invert<IEnumerable<string>>(IsSubset), + "&&" or ":overlaps:" => Overlaps, + "&&!" or ":disjoint:" => Invert<IEnumerable<string>>(Overlaps), + "==" 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 (new[] { valStr }, + (v1, v2, culture) => v1 is not null && v2 is not null && checklist(ToEnumerable(v1), ToEnumerable(v2), culture)); + } + Func<object, object, CultureInfo?, bool> checkItem = match.Groups["op"].Value switch { "=" or "" => GetStringEqCheck(), @@ -270,7 +296,37 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) }; } + 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 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); + return e1.All(l => e2.Contains(l, comparer)) && e2.Any(r => !e1.Contains(r, comparer)); + } + 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() { @@ -382,7 +438,19 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) [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 + ^(?<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: <= >= < > diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 95853e31..a09cc652 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -646,6 +646,12 @@ namespace TemplatesTests [DataRow("<cmp audible subtitle[3] = ' an '->true<-cmp>", "")] [DataRow("<cmp minutes > '42'->true<-cmp>", "true")] [DataRow("<cmp minutes > 42->true<-cmp>", "true")] + [DataRow("<cmp tag = 'Tag2'->true<-cmp>", "true")] + [DataRow("<cmp tag >> 'Tag2'->true<-cmp>", "true")] + [DataRow("<cmp tag :contains: 'Tag2'->true<-cmp>", "true")] + [DataRow("<cmp tag && tag->true<-cmp>", "true")] + [DataRow("<cmp tag >=> tag->true<-cmp>", "true")] + [DataRow("<cmp tag >-> tag->true<-cmp>", "")] public void Cmp_test(string template, string expected) { var bookDto = GetLibraryBook(); From c1b8cc894a4a043298e3fa2c37275013e12a8c2f Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Tue, 14 Apr 2026 00:41:22 +0200 Subject: [PATCH 04/11] add mathematical symbols --- .../ConditionalTagCollection[TClass].cs | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 18ffbac6..0f6b0ba8 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -227,10 +227,10 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) Func<int, int, CultureInfo?, bool> checkInt = match.Groups["op"].ValueSpan switch { "#=" => (v1, v2, _) => v1 == v2, - "#!=" => (v1, v2, _) => v1 != v2, - "#>=" or ">=" => (v1, v2, _) => v1 >= v2, + "#!=" or "≠" or "≠" => (v1, v2, _) => v1 != v2, + "#>=" or ">=" or "≥" => (v1, v2, _) => v1 >= v2, "#>" 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 }; @@ -244,17 +244,17 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) var stringEqCheck = GetStringEqCheck(); Func<IEnumerable<string>, IEnumerable<string>, CultureInfo?, bool> checklist = match.Groups["op"].ValueSpan switch { - ">>" or ":contains:" => Swap<IEnumerable<string>>(IsSubset), - ">=>" or ":superset:" => Swap<IEnumerable<string>>(IsSubset), - ">->" or ":proper_superset:" => Swap<IEnumerable<string>>(IsProperSubset), - "!>>" or ":not_contains:" => Invert(Swap<IEnumerable<string>>(IsSubset)), - "<<" or ":in:" => IsSubset, - "<=<" or ":subset:" => IsSubset, - "<-<" or ":proper_subset:" => IsProperSubset, - "!<<" or ":not_in:" => Invert<IEnumerable<string>>(IsSubset), - "&&" or ":overlaps:" => Overlaps, - "&&!" or ":disjoint:" => Invert<IEnumerable<string>>(Overlaps), - "==" or ":equals:" => (e1, e2, culture) => + "∋" 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); @@ -439,21 +439,21 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) [GeneratedRegex(""" (?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) + ≡ | == | :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: #<= #>= #< #> - | [<>]=? # - 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> From c72e5a3568e21557b67fca69ca5cc372c4a7e713 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Tue, 14 Apr 2026 02:48:03 +0200 Subject: [PATCH 05/11] fixed matching of first property --- .../NamingTemplate/ConditionalTagCollection[TClass].cs | 9 ++++++--- .../_Tests/LibationFileManager.Tests/TemplatesTests.cs | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 0f6b0ba8..01f4050c 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -156,11 +156,14 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) ^<(?<not>!)? # tags start with a '<'. Condtionals 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> + (?<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 - | [^:\#!≡=≠~<>≤≥&∉∌∈∌⋂⊆⊇⊂⊃-] # - match any character with some exclusions that should only be used in operands - ) +? (?<!\s)) # - don't let <property> end with a whitepace. Otherwise "<tagname = tag2->" would be matchable. + | (?: \[ (?: \\. # - properties may have optional formatting details enclosed in '[' and ']'. '\' escapes allways 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 diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index a09cc652..997a6cad 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -639,7 +639,7 @@ namespace TemplatesTests [DataRow("<cmp author != 'Sherlock'->true<-cmp>", "true")] [DataRow("<!cmp author != 'Sherlock'->false<-cmp>", "")] [DataRow("<cmp tag = 'Tag1'->true<-cmp>", "true")] - [DataRow("<cmp tag[separator(:)slice(-2..)] = 'Tag2:Tag3'->true<-cmp>", "true")] + [DataRow("<cmp tag[separator( : )slice(-2..)] = 'Tag2 : Tag3'->true<-cmp>", "true")] [DataRow("<cmp audible subtitle[3] = 'an'->true<-cmp>", "")] [DataRow("<cmp audible subtitle[3] = 'an '->true<-cmp>", "true")] [DataRow("<cmp audible subtitle[3] = ' an'->true<-cmp>", "")] From 475f20b3cb5ffd85e892083700ca8b5b3c222e0e Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Fri, 17 Apr 2026 08:35:43 +0200 Subject: [PATCH 06/11] unit tests added for new list operations --- .../TemplatesTests.cs | 97 +++++++++++-------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 997a6cad..fc14f1de 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -601,57 +601,70 @@ namespace TemplatesTests } [TestMethod] - [DataRow(@"<cmp codec = 'aac[lc]\mp3'->true<-cmp>", "true")] - [DataRow(@"<cmp codec = 'aac[lc]\mp4'->true<-cmp>", "")] [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[U] = 'A STUDY IN SCARLET: AN AUDIBLE ORIGINAL DRAMA'->true<-cmp>", "true")] - [DataRow("<cmp title #= '45'->true<-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 title = 'foo'->true<-cmp>", "")] [DataRow("<cmp ch count >= '99'->true<-cmp>", "true")] - [DataRow("<cmp ch count >= 1->true<-cmp>", "true")] - [DataRow("<cmp ch count > 1->true<-cmp>", "true")] - [DataRow("<cmp ch count <= 100->true<-cmp>", "true")] - [DataRow("<cmp ch count < 100->true<-cmp>", "true")] - [DataRow("<cmp ch count = 2->true<-cmp>", "true")] [DataRow("<cmp author >= '3'->true<-cmp>", "true")] - [DataRow("<cmp author >= 3->true<-cmp>", "")] - [DataRow("<cmp author >= 2->true<-cmp>", "true")] - [DataRow("<cmp author #= 2->true<-cmp>", "true")] [DataRow("<cmp author = 'Arthur Conan Doyle'->true<-cmp>", "true")] - [DataRow("<cmp author[format({L})] = 'Doyle'->true<-cmp>", "true")] - [DataRow("<!cmp author[format({L})] = 'Doyle'->false<-cmp>", "")] - [DataRow("<cmp author[format({L})] != 'Doyle'->true<-cmp>", "true")] - [DataRow("<!cmp author[format({L})] != 'Doyle'->false<-cmp>", "")] - [DataRow("<cmp author[format({L})separator(:)] = 'Doyle:Fry'->true<-cmp>", "true")] - [DataRow(@"<cmp author[slice(99)] =~ '.\*'->true<-cmp>", "")] - [DataRow("<cmp author[slice(99)separator(:)] =~ '.*'->true<-cmp>", "")] - [DataRow("<cmp author[slice(-9)separator(:)] =~ '.*'->true<-cmp>", "")] - [DataRow("<cmp author[slice(2..1)separator(:)] ~ '.*'->true<-cmp>", "")] - [DataRow("<cmp author[slice(-1..1)separator(:)] ~ '.*'->true<-cmp>", "")] - [DataRow("<cmp author[slice(-1..-2)separator(:)] ~ '.*'->true<-cmp>", "")] - [DataRow("<cmp author = 'Sherlock'->true<-cmp>", "")] - [DataRow("<!cmp author = 'Sherlock'->false<-cmp>", "false")] - [DataRow("<cmp author != 'Sherlock'->true<-cmp>", "true")] - [DataRow("<!cmp author != 'Sherlock'->false<-cmp>", "")] - [DataRow("<cmp tag = 'Tag1'->true<-cmp>", "true")] - [DataRow("<cmp tag[separator( : )slice(-2..)] = 'Tag2 : Tag3'->true<-cmp>", "true")] - [DataRow("<cmp audible subtitle[3] = 'an'->true<-cmp>", "")] - [DataRow("<cmp audible subtitle[3] = 'an '->true<-cmp>", "true")] - [DataRow("<cmp audible subtitle[3] = ' an'->true<-cmp>", "")] - [DataRow("<cmp audible subtitle[3] = ' an '->true<-cmp>", "")] - [DataRow("<cmp minutes > '42'->true<-cmp>", "true")] - [DataRow("<cmp minutes > 42->true<-cmp>", "true")] - [DataRow("<cmp tag = 'Tag2'->true<-cmp>", "true")] - [DataRow("<cmp tag >> 'Tag2'->true<-cmp>", "true")] - [DataRow("<cmp tag :contains: 'Tag2'->true<-cmp>", "true")] - [DataRow("<cmp tag && tag->true<-cmp>", "true")] - [DataRow("<cmp tag >=> tag->true<-cmp>", "true")] - [DataRow("<cmp tag >-> tag->true<-cmp>", "")] + // + [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(); From d5258b7e64b9d438ed6ec24056b43faa3065de1c Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Fri, 17 Apr 2026 08:46:52 +0200 Subject: [PATCH 07/11] fixed merge collision --- .../NamingTemplate/ConditionalTagCollection[TClass].cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 01f4050c..ceef5dbc 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -130,7 +130,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) ) +? (?<!\s)) # - don't let <property> end with a whitepace. Otherwise "<tagname = tag2->" would be matchable. (?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall start with an operator. So match whitespace first (?<check_or_op> # - capture inner part as <check_or_op> - (?:\\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + (?:\\. # - '\' escapes always the next character. Especially further '\' and the closing ']' |[^\\\]])* ) # - 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 @@ -159,7 +159,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) (?<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 allways the next 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. From 650b9c7f5b0c31dd40153abacebad4ad2508c0e8 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Fri, 17 Apr 2026 21:22:30 +0200 Subject: [PATCH 08/11] added docs for cmp tag --- docs/features/naming-templates.md | 82 +++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 25 deletions(-) diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index 27ac4a63..a02b95a4 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -62,7 +62,7 @@ To change how these properties are displayed, [read about custom formatters](#ta Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true. | Tag | Description | Type | -| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ----------- | +|--------------------------------------------------------------------|-------------------------------------------------------------------------------------------------| ----------- | | \<if series-\>...\<-if series\> | Only include if part of a book series or podcast | Conditional | | \<if podcast-\>...\<-if podcast\> | Only include if part of a podcast | Conditional | | \<if bookseries-\>...\<-if bookseries\> | Only include if part of a book series | Conditional | @@ -72,6 +72,7 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w | \<is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if the PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional | | \<is PROPERTY[FORMAT][[CHECK](#checks)]-\>...\<-is\> | Only include if the formatted PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional | | \<is PROPERTY[...separator(...)...][[CHECK](#checks)]-\>...\<-is\> | Only include if the joined form of all formatted values of a list PROPERTY satisfies the CHECK | Conditional | +| \<cmp 1st-PROPERTY [[CHECK](#checks)] 2nd-PROPERTY-\>...\<-cmp\> | Only include if two given PROPERTIES satisfy the CHECK | Conditional | **†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked. @@ -79,14 +80,15 @@ For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name. -| Inverted Tag | Description | Type | -| --------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------- | -| \<!if series-\>...\<-if series\> | Only include if _not_ part of a book series or podcast | Conditional | -| \<!if podcast-\>...\<-if podcast\> | Only include if _not_ part of a podcast | Conditional | -| \<!if bookseries-\>...\<-if bookseries\> | Only include if _not_ part of a book series | Conditional | -| \<!if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is _not_ a podcast series parent | Conditional | -| \<!has PROPERTY-\>...\<-has\> | Only include if the PROPERTY _does not_ have a value (i.e. is null or empty) | Conditional | -| \<!is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if neither the whole PROPERTY nor the values of a list PROPERTY satisfies the CHECK | Conditional | +| Inverted Tag | Description | Type | +|--------------------------------------------------------|--------------------------------------------------------------------------------------------------| ----------- | +| \<!if series-\>...\<-if series\> | Only include if _not_ part of a book series or podcast | Conditional | +| \<!if podcast-\>...\<-if podcast\> | Only include if _not_ part of a podcast | Conditional | +| \<!if bookseries-\>...\<-if bookseries\> | Only include if _not_ part of a book series | Conditional | +| \<!if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is _not_ a podcast series parent | Conditional | +| \<!has PROPERTY-\>...\<-has\> | Only include if the PROPERTY _does not_ have a value (i.e. is null or empty) | Conditional | +| \<!is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if neither the whole PROPERTY nor the values of a list PROPERTY satisfies the CHECK | Conditional | +| \<!cmp 1st-PROPERTY [CHECK] 2nd-PROPERTY-\>...\<-cmp\> | Only include if two given PROPERTIES _do not_ satisfy the CHECK | Conditional | **†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked. @@ -244,28 +246,58 @@ You can specify which part of a language you are interested in. ### Checks -| Check-Pattern | Description | Example | -| ---------------- | ------------------------------------------------------------------------------- | ------------------------------------------ | -| =STRING **†** | Matches if one item is equal to STRING (case ignored) | \<is tag[=Tag1]-\> | -| !=STRING **†** | Matches if one item is not equal to STRING (case ignored) | \<is first author[!=Arthur]-\> | -| ~STRING **†** | Matches if one items is matched by the regular expression STRING (case ignored) | \<is title[~(\[XYZ\]).*\\1]-\> | -| #=NUMBER **‡** | Matches if the number value is equal to NUMBER | \<is channels[#=2]-\> | -| #!=NUMBER **‡** | Matches if the number value is not equal to NUMBER | \<is author[#!=1]-\> | -| #\>=NUMBER **‡** | Matches if the number value is greater than or equal to NUMBER | \<is bitrate[#\>=128]-\> | -| #\>NUMBER **‡** | Matches if the number value is greater than NUMBER | \<is title[#\>30]-\> | -| #\<=NUMBER **‡** | Matches if the number value is less than or equal to NUMBER | \<is first narrator[format({F})][#\<=1]-\> | -| #\<NUMBER **‡** | Matches if the number value is less than NUMBER | \<is author[#\<3]-\> | +There are two formats for checks, with slightly different syntax for specifying parameters: -**†** STRING maybe escaped with a backslash. So even square brackets could be used. If a single backslash should be part of the string, it must be doubled. +`<is PROPERTY[CHECK-with-value]-\>...\<-is\>` -**‡** NUMBER checks on lists are checking the size of the list. If the value to check is a string, its length is used. +`<cmp 1st-PROPERTY CHECK 2nd-PROPERTY-\>...\<-cmp\>` -#### More complex examples +For `CHECK-with-value`, the value (2nd parameter) is specified directly after the check operator (e.g., `=`). This may +be a number or a string with escaped characters like `\]`. Single backslashes must be doubled if they are part of the +string. -This example will truncate the title to 4 characters and check its (trimmed) value to be "the" in any case: +`1st-PROPERTY` and `2nd-PROPERTY` may be any of the properties listed in the [Properties](#properties) section, or they +may be string or number literals. Use digits only for numbers. To specify a string literal, enclose it in single or +double quotes. If the string contains a single or double quote, escape it by doubling it. For example, to specify the +string literal `O'Reilly`, you can use either `'O''Reilly'` or `"O'Reilly"`. + +| String Checks | Unicode Operator | Description | Examples | +|---------------|------------------|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------| +| = | | Matches if values are equal (case-insensitive) | \<is tag[=Tag1]-\> | +| != | | Matches if values are not equal (case-insensitive) | \<is first author[!=Arthur]-\><br>\<cmp "foo" != 'bar'-\> | +| ~ | | Matches if the first parameter matches the regular expression specified by the second parameter (case-insensitive) | \<is title[~(\[XYZ\]).*\\1]-\> | + +| Number Checks | Unicode Operator | Description | Examples | +|---------------|------------------|----------------------------------------------------------------|-----------------------------------------------------------| +| #= **†** | | Matches if the number value equals NUMBER | \<is channels[#=2]-\><br>\<cmp channels #= 1-\> | +| #!= **†** | ≠ | Matches if the number value does not equal NUMBER | \<is author[#!=1]-\> | +| #\>= **†** | ≥ | Matches if the number value is greater than or equal to NUMBER | \<is bitrate[#\>=128]-\><br>\<cmp 44100 #>= samplerate-\> | +| #\> **†** | | Matches if the number value is greater than NUMBER | \<is title[#\>30]-\><br>\<cmp minutes #\> 60-\> | +| #\<= **†** | ≤ | Matches if the number value is less than or equal to NUMBER | \<is first narrator[format({F})][#\<=1]-\> | +| #\< **†** | | Matches if the number value is less than NUMBER | \<is author[#\<3]-\> | + +| List Checks | Unicode Operator | Description | Examples | +|--------------------------|------------------|----------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------| +| == \| :equals: | ≡ | Matches if both values contain the same elements | \<cmp author[slice(-2..-1)] == narrator[slice(..2)]-\> | +| \>> \| :contains: | ∋ | Matches if the first parameter contains the second parameter | \<is author[format({L})][∋King]-\> | +| !>> \| :not_contains: | ∌ | Matches if the first parameter does not contain the second parameter | \<cmp series :not_contains: 'Harry Potter'-\> | +| << \| :in: | ∈ | Matches if the first parameter is contained in the second parameter | \<cmp first author << author[slice(2..)]-\> | +| !<< \| :not_in: | ∉ | Matches if the first parameter is not contained in the second parameter | \<cmp author[slice(1)] ∉ narrator-\> | +| && \| :overlaps: | ⋂ | Matches if both values share at least one element | \<cmp author && narrator-\> | +| &&! \| :disjoint: | ⋂̸ | Matches if both values share no elements | \<cmp tag :disjoint: series-\> | +| <=< \| :subset: | ⊆ | Matches if all elements from the first parameter are found in the second parameter | \<cmp author[slice(-3..)] <=< author[slice(..-4)]-\> | +| >=> \| :superset: | ⊇ | Matches if the first parameter contains all elements from the second parameter | \<cmp author ⊇ narrator-\> | +| <-< \| :proper_subset: | ⊂ | Matches if all elements from the first parameter are found in the second parameter, and the second has additional elements | \<cmp author[slice(..2)] :proper_subset: author-\> | +| >-> \| :proper_superset: | ⊃ | Matches if the first parameter contains all elements from the second parameter plus at least one additional element | \<cmp author ⊃ narrator-\> | + +**†** NUMBER checks on lists check the size of the list. For string values, the string length is used. + +#### More Complex Examples + +This example truncates the title to 4 characters and checks if its (trimmed) value equals "the" (case-insensitive): `<is title[4][=the]>` -Here the second to fourth tag is taken and joined with a colon. The result is then checked to be equal to "Tag2:Tag3:Tag4": +This example takes tags 2 through 4, joins them with a colon, and checks if the result equals "Tag2:Tag3:Tag4": `<is tag[separator(:)slice(2..4)][=Tag2:Tag3:Tag4]->` From 962a5055b24cf96a4fdabde50b80143f9bffc4e6 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Mon, 20 Apr 2026 18:07:17 +0200 Subject: [PATCH 09/11] Fixed PR comments --- .../ConditionalTagCollection[TClass].cs | 242 +++++++++++------- .../NamingTemplate/RegExpExtensions.cs | 7 +- .../NamingTemplate/TagCollection.cs | 5 +- .../ConditionalTagCollectionTests.cs | 27 ++ .../TemplatesTests.cs | 12 +- 5 files changed, 193 insertions(+), 100 deletions(-) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index ceef5dbc..d2b23898 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Linq.Expressions; +using System.Runtime.InteropServices.ComTypes; using System.Text.RegularExpressions; namespace FileManager.NamingTemplate; @@ -99,7 +101,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) @@ -122,7 +124,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) // 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> @@ -141,7 +143,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) CreateConditionExpression = (exactName, property, checkString, _) => { - var (value, conditionEvaluator) = GetPredicate(exactName, checkString); + var (value, conditionEvaluator) = GetPredicateAndValue(exactName, checkString); return ConditionEvaluatorCall(conditionEvaluator, ValueProviderCall(templateTag, parameter, valueProvider, property), BuildArgument(value, conditionEvaluator.Method.GetParameters()[1].ParameterType)); @@ -153,7 +155,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) { 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+ # Separate the following with whitespace (?<property> # capture the <property> @@ -176,13 +178,10 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) , options); NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); - CreateConditionExpression = (exactName, property1, checkString, property2) => - { - var (_, conditionEvaluator) = GetPredicate(exactName, checkString); - return ConditionEvaluatorCall(conditionEvaluator, + 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) @@ -212,93 +211,111 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) return constant.Type == targetType ? constant : Expression.Convert(constant, targetType); } - private static (Object, ConditionEvaluator) GetPredicate(string exactName, string? checkString) + private static (object?, ConditionEvaluator) GetPredicateAndValue(string exactName, string? checkString) { if (checkString is null) - return ("", (v1, v2, _) => v1 switch + return (string.Empty, (v1, _, _) => v1 switch { null => false, IEnumerable<object> e => e.Any(), _ => !string.IsNullOrWhiteSpace(v1.ToString()) }); - var match = CheckRegex().Match(checkString); - var valStr = Unescape(match.Groups["val"]) ?? ""; + var match = GetMatch(exactName, checkString); + var valStr = Unescape(match.Groups["val"]); + var (evaluator, opGroup) = GetPredicate(exactName, match); - if (match.Groups["num_op"].Success) + return (opGroup.Name switch { - Func<int, int, CultureInfo?, bool> checkInt = match.Groups["op"].ValueSpan 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 - }; - int.TryParse(valStr, out var valInt); - return (valInt, - (v1, v2, culture) => v1 is not null && v2 is not null && checkInt(ToIntObject(v1), ToIntObject(v2), culture)); - } - - if (match.Groups["list_op"].Success) - { - var stringEqCheck = GetStringEqCheck(); - Func<IEnumerable<string>, IEnumerable<string>, CultureInfo?, bool> checklist = match.Groups["op"].ValueSpan 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 (new[] { valStr }, - (v1, v2, culture) => v1 is not null && v2 is not null && checklist(ToEnumerable(v1), ToEnumerable(v2), culture)); - } - - Func<object, object, CultureInfo?, bool> checkItem = match.Groups["op"].Value 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 (valStr, - (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) - }); + "num_op" => valStr is null ? null : int.Parse(valStr), + "list_op" => new[] { valStr ?? string.Empty }, + _ => valStr ?? string.Empty + }, evaluator); } - private static int ToIntObject(object value) + private static ConditionEvaluator GetPredicate(string exactName, string? checkString) + { + return GetPredicate(exactName, GetMatch(exactName, checkString)).Item1; + } + + private static Match GetMatch(string exactName, string? checkString) + { + return CheckRegex().TryMatch(checkString, out var match) + ? match + : throw new ArgumentException($"Invalid check or operator format in conditional tag '{exactName}'. Check string: '{checkString}'"); + } + + private static (ConditionEvaluator, Group) GetPredicate(string exactName, Match match) + { + var group = match.Groups["num_op"]; + if (group.Success) + { + return (GetPredicateForNumOp(exactName, group.ValueSpan), group); + } + + group = match.Groups["list_op"]; + if (group.Success) + { + return (GetPredicateForListOp(exactName, group.ValueSpan), group); + } + + group = match.Groups["op"]; + return (GetPredicateForStringOp(exactName, group.ValueSpan), group); + } + + private static ConditionEvaluator GetPredicateForNumOp(string exactName, 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, - _ => throw new ArgumentOutOfRangeException() + _ => null // language and such shall never match }; } + private static ConditionEvaluator GetPredicateForListOp(string exactName, 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 @@ -310,6 +327,26 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) }; } + 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); @@ -325,7 +362,9 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) 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); @@ -333,7 +372,17 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) private static Func<object, object, CultureInfo?, bool> GetStringEqCheck() { - return (v1, v2, culture) => GetStringComparer(culture).Equals(v1?.ToString(), v2.ToString()); + 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) @@ -347,28 +396,29 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) /// 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, object, CultureInfo?, bool> GetRegExpCheck(string exactName) { return (v1, v2, _) => { - Regex regex; var pattern = v2.ToString() ?? ""; - try + var regex = RegexCache.GetOrAdd(pattern, p => { - // 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); - } + 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 _). @@ -399,10 +449,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 @@ -441,7 +491,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) [GeneratedRegex(""" (?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 + ^(?>(?<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 @@ -458,11 +508,11 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) | \#[<>]=? # - numerical operators: #<= #>= #< #> | [<>]=? | ≤ | ≥ # - numerical operators: <= >= < > ≤ ≥ ) | [=!]?~ | !=? | =? # - string comparison operators including ~ for regexp, = and !=. No operator is like = - ) \s*? # ignore space between operator and value + )) \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 + (?:\d)+ # - numerical operators have to be followed by a number + | (?:\\.|[^\\])+ ) # - string for comparison. May be empty. Capturing also all whitespace up to the end as this must have been escaped. + )?$ # match to the end """)] private static partial Regex CheckRegex(); } diff --git a/Source/FileManager/NamingTemplate/RegExpExtensions.cs b/Source/FileManager/NamingTemplate/RegExpExtensions.cs index 57a21ef0..6d93813b 100644 --- a/Source/FileManager/NamingTemplate/RegExpExtensions.cs +++ b/Source/FileManager/NamingTemplate/RegExpExtensions.cs @@ -33,8 +33,13 @@ public static class RegExpExtensions extension(Regex regex) { - public bool TryMatch(string input, [NotNullWhen(true)] out Match? match) + public bool TryMatch(string? input, [NotNullWhen(true)] out Match? match) { + if (input is null) + { + match = null; + return false; + } var m = regex.Match(input); match = m.Success ? m : null; return m.Success; diff --git a/Source/FileManager/NamingTemplate/TagCollection.cs b/Source/FileManager/NamingTemplate/TagCollection.cs index a641edeb..50ed8198 100644 --- a/Source/FileManager/NamingTemplate/TagCollection.cs +++ b/Source/FileManager/NamingTemplate/TagCollection.cs @@ -1,5 +1,6 @@ using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -12,6 +13,8 @@ namespace FileManager.NamingTemplate; /// <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); } diff --git a/Source/_Tests/FileManager.Tests/ConditionalTagCollectionTests.cs b/Source/_Tests/FileManager.Tests/ConditionalTagCollectionTests.cs index eb9919af..2ede9eea 100644 --- a/Source/_Tests/FileManager.Tests/ConditionalTagCollectionTests.cs +++ b/Source/_Tests/FileManager.Tests/ConditionalTagCollectionTests.cs @@ -27,6 +27,33 @@ public class ConditionalTagCollectionTests private static object? TryGetValue(ITemplateTag _, TestObject obj, string condition, CultureInfo? culture) => obj.Value; + /// <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. diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 6eaa9b46..3b22a255 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -550,6 +550,10 @@ namespace TemplatesTests [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>", "")] @@ -581,6 +585,10 @@ namespace TemplatesTests [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 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(); @@ -875,7 +883,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; @@ -956,7 +964,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; From c1e9911ade78a9f875da356dcb33aacfc39003eb Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Mon, 20 Apr 2026 18:43:33 +0200 Subject: [PATCH 10/11] Small fixes on auto checks --- .../ConditionalTagCollection[TClass].cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index d2b23898..51e18e7f 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -1,11 +1,9 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Linq.Expressions; -using System.Runtime.InteropServices.ComTypes; using System.Text.RegularExpressions; namespace FileManager.NamingTemplate; @@ -127,9 +125,8 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) ^<(?<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> - [^<=~>!] # - match any character with some exclusions that should only be used in operands - ) +? (?<!\s)) # - don't let <property> end with a whitepace. Otherwise "<tagname = tag2->" would be matchable. + (?<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_or_op> # - capture inner part as <check_or_op> (?:\\. # - '\' escapes always the next character. Especially further '\' and the closing ']' @@ -211,7 +208,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) return constant.Type == targetType ? constant : Expression.Convert(constant, targetType); } - private static (object?, ConditionEvaluator) GetPredicateAndValue(string exactName, string? checkString) + private static (object, ConditionEvaluator) GetPredicateAndValue(string exactName, string? checkString) { if (checkString is null) return (string.Empty, (v1, _, _) => v1 switch @@ -227,7 +224,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) return (opGroup.Name switch { - "num_op" => valStr is null ? null : int.Parse(valStr), + "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); @@ -263,7 +260,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) return (GetPredicateForStringOp(exactName, group.ValueSpan), group); } - private static ConditionEvaluator GetPredicateForNumOp(string exactName, ReadOnlySpan<char> opString) + private static ConditionEvaluator GetPredicateForNumOp(string _, ReadOnlySpan<char> opString) { Func<int?, int?, CultureInfo?, bool> checkInt = opString switch { @@ -275,7 +272,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) "#<" 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);; + return (v1, v2, culture) => ToIntObject(v1) is { } i1 && ToIntObject(v2) is { } i2 && checkInt(i1, i2, culture); } private static int? ToIntObject(object? value) @@ -292,7 +289,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) }; } - private static ConditionEvaluator GetPredicateForListOp(string exactName, ReadOnlySpan<char> opString) + private static ConditionEvaluator GetPredicateForListOp(string _, ReadOnlySpan<char> opString) { var checklist = opString switch { From ea33d8933a2e989ed17819ee47e901e9daa92634 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co <Dev@JoBeCo.de> Date: Mon, 20 Apr 2026 19:24:21 +0200 Subject: [PATCH 11/11] test added with colons in tag comparison --- Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 3b22a255..c755c5e8 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -622,6 +622,8 @@ namespace TemplatesTests [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")]