introduce cmp tag as check with two operands

This commit is contained in:
Jo-Be-Co
2026-04-14 00:30:34 +02:00
parent a010da5251
commit 810ea90770
4 changed files with 158 additions and 13 deletions

View File

@@ -63,6 +63,17 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider));
}
/// <summary>
/// Register a conditional tag.
/// </summary>
/// <param name="templateTag"></param>
/// <param name="valueProvider1">A <see cref="ValueProvider{T}"/> to get the first condition's value. The values will be evaluated by a check specified by the tag itself.</param>
/// <param name="valueProvider2">A <see cref="ValueProvider{T}"/> to get the second condition's value. The values will be evaluated by a check specified by the tag itself.</param>
public void Add(ITemplateTag templateTag, ValueProvider<TClass> valueProvider1, ValueProvider<TClass> valueProvider2)
{
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider1, valueProvider2));
}
private partial class ConditionalTag : TagBase, IClosingPropertyTag
{
private static readonly TimeSpan RegexpCheckTimeout = TimeSpan.FromMilliseconds(100);
@@ -70,7 +81,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
public override Regex NameMatcher { get; }
public Regex NameCloseMatcher { get; }
private Func<string, string?, string?, Expression> CreateConditionExpression { get; }
private Func<string, string?, string?, string?, Expression> CreateConditionExpression { get; }
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
: base(templateTag, conditionExpression)
@@ -79,7 +90,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
NameMatcher = new Regex($"^<(?<not>!)?{tagNameRe}->", options);
NameCloseMatcher = new Regex($"^<-{tagNameRe}>", options);
CreateConditionExpression = (_, _, _) => conditionExpression;
CreateConditionExpression = (_, _, _, _) => conditionExpression;
}
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider<TClass> valueProvider, ConditionEvaluator conditionEvaluator)
@@ -98,7 +109,7 @@ public partial class ConditionalTagCollection<TClass>(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<TClass>(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<TClass>(bool caseSensitive = true)
};
}
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider<TClass> valueProvider1, ValueProvider<TClass> valueProvider2)
: base(templateTag, Expression.Constant(false))
{
NameMatcher = new Regex($"""
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
^<(?<not>!)? # tags start with a '<'. 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>
'(?:[^']|'')*' # - 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.
\s+ # Separate the following operand with whitespace
(?<check_or_op> # capture operator in <op> and <num_op> with every char escapable
[\#!≡=≠~<>≤≥&∉∌∈∌⋂⊆⊇⊂⊃-]+ # allow a wide range of operators, all non alphanumeric
| :[a-z_]+: # allow :named: operators for readability, e.g. :contains:
) \s+ # ignore space between operator and second property
(?<second_property>.+? # - capture the <second_property> non greedy so it won't end on whitespace
(?<!\s)) # - don't let <second_property> end with a whitepace. Otherwise "<tagname tag1 = ->" would be matchable.
\s*-> # Opening tags end with '->' and closing tags begin with '<-', so both sides visually point toward each other
"""
, options);
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
CreateConditionExpression = (exactName, property1, checkString, property2) =>
{
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<TClass>(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<TClass>(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<object, object, CultureInfo?, bool> checkItem = Unescape(match.Groups["op"]) switch
Func<object, object, CultureInfo?, bool> checkItem = match.Groups["op"].Value switch
{
"=" or "" => GetStringEqCheck(),
"!=" or "!" => Invert(GetStringEqCheck()),
@@ -205,7 +251,9 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
(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)
});
}
@@ -222,13 +270,18 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
};
}
private static Func<object, object, CultureInfo?, bool> Invert(Func<object, object, CultureInfo?, bool> condition) => (v1, v2, culture) => !condition(v1, v2, culture);
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<object, object, CultureInfo?, bool> 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);
}
/// <summary>
/// Build a regular expression check. Uses culture-invariant matching for thread-safety and consistency.
/// Applies a timeout to prevent regex patterns from causing excessive backtracking and blocking.
@@ -322,7 +375,8 @@ public partial class ConditionalTagCollection<TClass>(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<TClass>(bool caseSensitive = true)
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
^(?<op>(?<num_op> # anchor at start of linecapture operator in <op> and <num_op> with every char escapable
\#!?= # - numerical operators: #= #!=
| \#[<>]=? # - numerical operators: #>= #<= #> #<
| [<>]=? # - numerical operators: >= <= > <
| \#[<>]=? # - 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>

View File

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

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AaxDecrypter;
using Dinah.Core;
using FileManager;
@@ -19,7 +20,7 @@ public interface ITemplate
static abstract IEnumerable<TagCollection> TagCollections { get; }
}
public abstract class Templates
public abstract partial class Templates
{
public const string ErrorFullPathIsInvalid = @"No colons or full paths allowed. Eg: should not start with C:\";
public const string WarningNoChapterNumberTag = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
@@ -336,6 +337,7 @@ public abstract class Templates
private static readonly ConditionalTagCollection<CombinedDto> 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<PropertyTagCollection<LibraryBookDto>>())
{
if (c.TryGetObject(property, dtos.LibraryBook, culture, out var value))
@@ -367,6 +386,9 @@ public abstract class Templates
return null;
}
[GeneratedRegex(@"^\s*(?<quote>['""])(?<value>(?:(?<double>\k<quote>{2})|.)*)\k<quote>\s*$")]
private static partial Regex StringValueRegex();
private static bool HasValue(object? value, object? _, CultureInfo? culture)
{
bool CheckItem(object o, CultureInfo? _) => !string.IsNullOrWhiteSpace(o.ToString());

View File

@@ -600,6 +600,72 @@ namespace TemplatesTests
fileTemplate.Warnings.Should().HaveCount(1); // "Should use tags. Eg: <title>"
}
[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")]