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")]