From cd9a0707844af13170a2398a6f294ac3e70bbfb8 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Sat, 21 Mar 2026 00:24:49 +0100 Subject: [PATCH] Allow two step formatting to get checkable values and not only single stringa Introduce Tag. Like but with additional check on content. Retrieve objects instead of string for conditions Pass undefined formats as null instead of empty strings --- .../NamingTemplate/CommonFormatters.cs | 39 +++- .../ConditionalTagCollection[TClass].cs | 139 ++++++++++++- .../NamingTemplate/NamingTemplate.cs | 13 +- .../PropertyTagCollection[TClass].cs | 194 +++++++++++++----- Source/FileManager/NamingTemplate/TagBase.cs | 28 +-- .../NamingTemplate/TagCollection.cs | 5 +- .../Templates/ContributorDto.cs | 2 +- .../Templates/IListFormat[TList].cs | 32 +-- .../Templates/NameListFormat.cs | 15 +- .../Templates/SeriesDto.cs | 2 +- .../Templates/SeriesListFormat.cs | 13 +- .../Templates/TemplateTags.cs | 1 + .../Templates/Templates.cs | 25 ++- .../FileNamingTemplateTests.cs | 14 +- .../TemplatesTests.cs | 192 +++++++++-------- docs/features/naming-templates.md | 48 +++-- 16 files changed, 529 insertions(+), 233 deletions(-) diff --git a/Source/FileManager/NamingTemplate/CommonFormatters.cs b/Source/FileManager/NamingTemplate/CommonFormatters.cs index 04ad3454..0b371327 100644 --- a/Source/FileManager/NamingTemplate/CommonFormatters.cs +++ b/Source/FileManager/NamingTemplate/CommonFormatters.cs @@ -9,12 +9,29 @@ public static partial class CommonFormatters { public const string DefaultDateFormat = "yyyy-MM-dd"; - public delegate string PropertyFormatter(ITemplateTag templateTag, T? value, string formatString, CultureInfo? culture); + public delegate TFormatted? PropertyFormatter(ITemplateTag templateTag, TProperty? value, string? formatString, CultureInfo? culture); - public static string StringFormatter(ITemplateTag _, string? value, string formatString, CultureInfo? culture) + public delegate string? PropertyFinalizer(ITemplateTag templateTag, T? value, CultureInfo? culture); + + public static PropertyFinalizer ToPropertyFormatter(PropertyFormatter preFormatter, + PropertyFinalizer finalizer) + { + return (templateTag, value, culture) => finalizer(templateTag, preFormatter(templateTag, value, null, culture), culture); + } + + public static PropertyFinalizer ToFinalizer(PropertyFormatter formatter) + { + return (templateTag, value, culture) => formatter(templateTag, value, null, culture); + } + + public static string? StringFinalizer(ITemplateTag templateTag, string? value, CultureInfo? culture) => value ?? ""; + + public static TPropertyValue? IdlePreFormatter(ITemplateTag templateTag, TPropertyValue? value, string? formatString, CultureInfo? culture) => value; + + public static string StringFormatter(ITemplateTag _, string? value, string? formatString, CultureInfo? culture) => _StringFormatter(value, formatString, culture); - private static string _StringFormatter(string? value, string formatString, CultureInfo? culture) + private static string _StringFormatter(string? value, string? formatString, CultureInfo? culture) { if (string.IsNullOrEmpty(value)) return string.Empty; if (string.IsNullOrEmpty(formatString)) return value; @@ -47,7 +64,7 @@ public static partial class CommonFormatters // is this function is called from toString implementation of the IFormattable interface, we only get a IFormatProvider var culture = provider as CultureInfo ?? (provider?.GetFormat(typeof(CultureInfo)) as CultureInfo); - return TagFormatRegex().Replace(templateString, GetValueForMatchingTag); + return CollapseSpacesAndTrimRegex().Replace(TagFormatRegex().Replace(templateString, GetValueForMatchingTag), ""); string GetValueForMatchingTag(Match m) { @@ -64,6 +81,10 @@ public static partial class CommonFormatters } } + // Matches runs of spaces followed by a space as well as runs of spaces at the beginning or the end of a string (does NOT touch tabs/newlines). + [GeneratedRegex(@"^ +| +(?=$| )")] + private static partial Regex CollapseSpacesAndTrimRegex(); + // The templateString is scanned for contained braces with an enclosed tagname. // The tagname may be followed by an optional format specifier separated by a colon. // All other parts of the template string are left untouched as well as the braces where the tagname is unknown. @@ -71,13 +92,13 @@ public static partial class CommonFormatters [GeneratedRegex(@"\{(?[[A-Z]+|#)(?::(?.*?))?\}", RegexOptions.IgnoreCase)] private static partial Regex TagFormatRegex(); - public static string FormattableFormatter(ITemplateTag _, IFormattable? value, string formatString, CultureInfo? culture) + public static string FormattableFormatter(ITemplateTag _, IFormattable? value, string? formatString, CultureInfo? culture) => value?.ToString(formatString, culture) ?? ""; - public static string IntegerFormatter(ITemplateTag templateTag, int value, string formatString, CultureInfo? culture) + public static string IntegerFormatter(ITemplateTag templateTag, int value, string? formatString, CultureInfo? culture) => FloatFormatter(templateTag, value, formatString, culture); - public static string FloatFormatter(ITemplateTag _, float value, string formatString, CultureInfo? culture) + public static string FloatFormatter(ITemplateTag _, float value, string? formatString, CultureInfo? culture) { culture ??= CultureInfo.CurrentCulture; if (!int.TryParse(formatString, out var numDigits) || numDigits <= 0) return value.ToString(formatString, culture); @@ -89,7 +110,7 @@ public static partial class CommonFormatters return new string('0', zeroPad) + strValue; } - public static string DateTimeFormatter(ITemplateTag _, DateTime value, string formatString, CultureInfo? culture) + public static string DateTimeFormatter(ITemplateTag _, DateTime value, string? formatString, CultureInfo? culture) { culture ??= CultureInfo.InvariantCulture; if (string.IsNullOrEmpty(formatString)) @@ -97,7 +118,7 @@ public static partial class CommonFormatters return value.ToString(formatString, culture); } - public static string LanguageShortFormatter(ITemplateTag templateTag, string? language, string formatString, CultureInfo? culture) + public static string LanguageShortFormatter(ITemplateTag templateTag, string? language, string? formatString, CultureInfo? culture) { return StringFormatter(templateTag, language?.Trim(), "3u", culture); } diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 1b0b5356..db64723f 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Linq; using System.Linq.Expressions; using System.Text.RegularExpressions; @@ -23,11 +24,11 @@ internal interface IClosingPropertyTag : IPropertyTag bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag); } -public delegate string? ValueProvider(ITemplateTag templateTag, T value, string condition, CultureInfo? culture); +public delegate object? ValueProvider(ITemplateTag templateTag, T value, string condition, CultureInfo? culture); -public delegate bool ConditionEvaluator(string? value, CultureInfo? culture); +public delegate bool ConditionEvaluator(object? value, CultureInfo? culture); -public class ConditionalTagCollection(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive) +public partial class ConditionalTagCollection(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive) { /// /// Register a conditional tag. @@ -45,19 +46,29 @@ public class ConditionalTagCollection(bool caseSensitive = true) : TagCo /// Register a conditional tag. /// /// - /// A to get the condition's value - /// + /// A to get the condition's value + /// A to evaluate the condition's value public void Add(ITemplateTag templateTag, ValueProvider valueProvider, ConditionEvaluator conditionEvaluator) { AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider, conditionEvaluator)); } - private class ConditionalTag : TagBase, IClosingPropertyTag + /// + /// Register a conditional tag. + /// + /// + /// A to get the condition's value. The value will be evaluated by a check specified by the tag itself. + public void Add(ITemplateTag templateTag, ValueProvider valueProvider) + { + AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider)); + } + + private partial class ConditionalTag : TagBase, IClosingPropertyTag { 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) @@ -65,7 +76,8 @@ public class ConditionalTagCollection(bool caseSensitive = true) : TagCo var tagNameRe = TagNameForRegex(); 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) @@ -84,10 +96,41 @@ public class ConditionalTagCollection(bool caseSensitive = true) : TagCo , options); NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); - CreateConditionExpression = property + CreateConditionExpression = (property, _) => ConditionEvaluatorCall(templateTag, parameter, valueProvider, property, conditionEvaluator); } + 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 check enclosed in [] at the end of the tag the property itself migth 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. + (?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall be trimmed. So match whitespace first + (? # - capture inner part as + (?:\\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + |[^\\\]])*? # - match any character except '\' and ']' non greedy so the match won't end whith whitespace + )\s* # - the whitespace after the check is optional + \])? # - closing the check part + )? # end of optional property and check part + \s*-> # Opening tags end with '->' and closing tags begin with '<-', so both sides visually point toward each other + """ + , options); + NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); + + CreateConditionExpression = (property, checkString) => + { + var conditionEvaluator = GetPredicate(checkString); + return ConditionEvaluatorCall(templateTag, parameter, valueProvider, property, conditionEvaluator); + }; + } + private static MethodCallExpression ConditionEvaluatorCall(ITemplateTag templateTag, ParameterExpression parameter, ValueProvider valueProvider, string? property, ConditionEvaluator conditionEvaluator) { @@ -109,6 +152,66 @@ public class ConditionalTagCollection(bool caseSensitive = true) : TagCo CultureParameter); } + private static ConditionEvaluator GetPredicate(string? checkString) + { + if (checkString == null) + return DefaultPredicate; + + var match = CheckRegex().Match(checkString); + + var valStr = match.Groups["val"].Value; + + var checkItem = match.Groups["op"].ValueSpan switch + { + "=" or "" => (v, culture) => VComparedToStr(v, culture, valStr) == 0, + "!=" or "!" => (v, culture) => VComparedToStr(v, culture, valStr) != 0, + "~" => GetRegExpCheck(valStr), + _ => DefaultPredicate, + }; + return (v, culture) => v switch + { + null => false, + IEnumerable e => e.Any(o => checkItem(o, culture)), + _ => checkItem(v, culture) + }; + } + + private static int VComparedToStr(object? v, CultureInfo? culture, string valStr) + { + culture ??= CultureInfo.CurrentCulture; + return culture.CompareInfo.Compare(v?.ToString()?.Trim(), valStr, CompareOptions.IgnoreCase); + } + + /// + /// build a regular expression check which take the into account. + /// + /// + /// check function to validate an object + private static ConditionEvaluator GetRegExpCheck(string valStr) + { + return (v, culture) => + { + var old = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = culture ?? CultureInfo.CurrentCulture; + return Regex.IsMatch(v?.ToString().Trim() ?? "", valStr, RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + finally + { + CultureInfo.CurrentCulture = old; + } + }; + } + + // without any special check only the existance of the property is checked. Strings need to be non empty. + private static readonly ConditionEvaluator DefaultPredicate = (v, _) => v switch + { + null => false, + IEnumerable e => e.Any(), + _ => !string.IsNullOrWhiteSpace(v.ToString()) + }; + public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag) { var match = NameCloseMatcher.Match(templateString); @@ -124,10 +227,24 @@ public class ConditionalTagCollection(bool caseSensitive = true) : TagCo return false; } - protected override Expression GetTagExpression(string exactName, Dictionary matchData) + protected override Expression GetTagExpression(string exactName, Dictionary matchData, OutputType outputType) { - var getBool = CreateConditionExpression(matchData.GetValueOrDefault("property")?.Value); + var getBool = CreateConditionExpression( + matchData.GetValueOrDefault("property")?.Value, + Unescape(matchData.GetValueOrDefault("check"))); return matchData["not"].Success ? Expression.Not(getBool) : getBool; } + + [GeneratedRegex(""" + (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # + ^\s* # anchor at start of line trimming leading whitespace + (? # capture operator in and + ~|!=?|=? # - string comparison operators including ~ for regexp. No operator is like = + ) \s* # ignore space between operator and value + (? # capture value in + .*? # - string for comparison. May be empty. Non-greedy capture resulting in no whitespace at the end + )\s*$ # trimming up to the end + """)] + private static partial Regex CheckRegex(); } } diff --git a/Source/FileManager/NamingTemplate/NamingTemplate.cs b/Source/FileManager/NamingTemplate/NamingTemplate.cs index 1423f50e..41d3c386 100644 --- a/Source/FileManager/NamingTemplate/NamingTemplate.cs +++ b/Source/FileManager/NamingTemplate/NamingTemplate.cs @@ -65,8 +65,8 @@ public class NamingTemplate var namingTemplate = new NamingTemplate(tagCollections); try { - BinaryNode intermediate = namingTemplate.IntermediateParse(template); - Expression evalTree = GetExpressionTree(intermediate); + var intermediate = namingTemplate.IntermediateParse(template); + var evalTree = GetExpressionTree(intermediate); namingTemplate._templateToString = Expression.Lambda(evalTree, tagCollections.Select(tc => tc.Parameter).Append(TagCollection.CultureParameter)).Compile(); } @@ -108,11 +108,11 @@ public class NamingTemplate BinaryNode topNode = BinaryNode.CreateRoot(); BinaryNode? currentNode = topNode; - List literalChars = new(); + List literalChars = []; while (templateString.Length > 0) { - if (StartsWith(templateString, out var exactPropertyName, out var propertyTag, out var valueExpression)) + if (StartsWith(templateString, OutputType.String, out var exactPropertyName, out var propertyTag, out var valueExpression)) { CheckAndAddLiterals(); @@ -183,11 +183,12 @@ public class NamingTemplate } } - private bool StartsWith(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? valueExpression) + private bool StartsWith(string template, OutputType outputType, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, + [NotNullWhen(true)] out Expression? valueExpression) { foreach (var pc in _tagCollections) { - if (pc.StartsWith(template, out exactName, out propertyTag, out valueExpression)) + if (pc.StartsWith(template, outputType, out exactName, out propertyTag, out valueExpression)) return true; } diff --git a/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs index 31e1d9d8..43ba013f 100644 --- a/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs @@ -41,10 +41,51 @@ public class PropertyTagCollection : TagCollection /// Optional formatting function that accepts the property /// and a formatting string and returns the value the formatted string. If null, use the default /// formatter if present, or - public void Add(ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter? formatter = null) + public void Add(ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter? formatter = null) where TProperty : struct => RegisterWithFormatter(templateTag, propertyGetter, formatter); + /// + /// Register a property + /// + /// Type of the property from + /// + /// A Func to get the property value from + /// Optional formatting function that accepts the property + /// and a formatting string and returns the value formatted to string. If null, use the default + /// formatter if present, or + public void Add(ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter? formatter = null) + => RegisterWithFormatter(templateTag, propertyGetter, formatter); + + /// + /// Register a nullable value type property. + /// + /// Type of the property from + /// + /// + /// A Func to get the property value from + /// A Func used for first filtering and formatting. The result might be a + /// This Func assures a string result + /// formatter if present, or + public void Add(ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter preFormatter, + PF.PropertyFinalizer finalizer) + where TProperty : struct + => RegisterWithPreFormatter(templateTag, propertyGetter, preFormatter, finalizer); + + /// + /// Register a nullable value type property. + /// + /// Type of the property from + /// + /// + /// A Func to get the property value from + /// A Func used for first filtering and formatting. The result might be a + /// This Func assures a string result + /// formatter if present, or + public void Add(ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter preFormatter, + PF.PropertyFinalizer finalizer) + => RegisterWithPreFormatter(templateTag, propertyGetter, preFormatter, finalizer); + /// /// Register a nullable value type property. /// @@ -56,18 +97,6 @@ public class PropertyTagCollection : TagCollection where TProperty : struct => RegisterWithToString(templateTag, propertyGetter, toString); - /// - /// Register a property - /// - /// Type of the property from - /// - /// A Func to get the property value from - /// Optional formatting function that accepts the property - /// and a formatting string and returns the value formatted to string. If null, use the default - /// formatter if present, or - public void Add(ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter? formatter = null) - => RegisterWithFormatter(templateTag, propertyGetter, formatter); - /// /// Register a property. /// @@ -79,17 +108,33 @@ public class PropertyTagCollection : TagCollection => RegisterWithToString(templateTag, propertyGetter, toString); private void RegisterWithFormatter - (ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter? formatter) + (ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter? formatter) + { + formatter ??= GetDefaultFormatter(); + + if (formatter is null) + RegisterWithToString(templateTag, propertyGetter, ToStringFunc); + else + RegisterWithFormatters(templateTag, propertyGetter, formatter, PF.StringFinalizer, PF.ToFinalizer(formatter)); + } + + private void RegisterWithPreFormatter + (ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter preFormatter, + PF.PropertyFinalizer finalizer) + { + PF.PropertyFinalizer formatter = PF.ToPropertyFormatter(preFormatter, finalizer); + RegisterWithFormatters(templateTag, propertyGetter, preFormatter, finalizer, formatter); + } + + private void RegisterWithFormatters + (ITemplateTag templateTag, Func propertyGetter, PF.PropertyFormatter preFormatter, + PF.PropertyFinalizer finalizer, PF.PropertyFinalizer formatter) { ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag)); ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter)); var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter); - formatter ??= GetDefaultFormatter(); - - AddPropertyTag(formatter is null - ? new PropertyTag(templateTag, Options, expr, ToStringFunc) - : new PropertyTag(templateTag, Options, expr, formatter)); + AddPropertyTag(new PropertyTag(templateTag, Options, expr, preFormatter, finalizer, formatter)); } private void RegisterWithToString @@ -99,17 +144,17 @@ public class PropertyTagCollection : TagCollection ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter)); var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter); - AddPropertyTag(new PropertyTag(templateTag, Options, expr, toString)); + AddPropertyTag(new PropertyTag(templateTag, Options, expr, toString)); } private static string ToStringFunc(T propertyValue) => propertyValue?.ToString() ?? ""; - private PF.PropertyFormatter? GetDefaultFormatter() + private PF.PropertyFormatter? GetDefaultFormatter() { try { var del = _defaultFormatters.FirstOrDefault(kvp => kvp.Key == typeof(T)).Value; - return del is null ? null : Delegate.CreateDelegate(typeof(PF.PropertyFormatter), del.Target, del.Method) as PF.PropertyFormatter; + return del is null ? null : Delegate.CreateDelegate(typeof(PF.PropertyFormatter), del.Target, del.Method) as PF.PropertyFormatter; } catch { return null; } } @@ -119,26 +164,29 @@ public class PropertyTagCollection : TagCollection /// /// Name of the tag value to get /// The property class from which the tag's value is read - /// 's string value if it is in this collection, otherwise null + /// + /// 's object value if it is in this collection, otherwise null /// True if the is in this collection, otherwise false - public bool TryGetValue(string tagName, TClass @object, CultureInfo? culture, [NotNullWhen(true)] out string? value) + public bool TryGetObject(string tagName, TClass @object, CultureInfo? culture, out object? value) { value = null; - if (!StartsWith($"<{tagName}>", out _, out _, out var valueExpression)) + if (!StartsWith($"<{tagName}>", OutputType.Object, out _, out _, out var valueExpression)) return false; - var func = Expression.Lambda>(valueExpression, Parameter, CultureParameter).Compile(); + var func = Expression.Lambda>(valueExpression, Parameter, CultureParameter).Compile(); value = func(@object, culture); return true; } - private class PropertyTag : TagBase + private class PropertyTag : TagBase { public override Regex NameMatcher { get; } - private Func CreateToStringExpression { get; } + private Func CreateToStringExpression { get; } + private Func CreateToObjectExpression { get; } - public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PF.PropertyFormatter formatter) + public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PF.PropertyFormatter preFormatter, + PF.PropertyFinalizer finalizer, PF.PropertyFinalizer formatter) : base(templateTag, propertyGetter) { NameMatcher = new Regex($""" @@ -156,20 +204,45 @@ public class PropertyTagCollection : TagCollection """ , options); + CreateToObjectExpression = (expVal, format) => + format is null + ? expVal + : Expression.Call( + preFormatter.Target is null ? null : Expression.Constant(preFormatter.Target), + preFormatter.Method, + Expression.Constant(templateTag), + expVal, + Expression.Constant(format), + CultureParameter); + CreateToStringExpression = (expVal, format) => - Expression.Call( - formatter.Target is null ? null : Expression.Constant(formatter.Target), - formatter.Method, - Expression.Constant(templateTag), - expVal, - Expression.Constant(format), - CultureParameter); + format is null + ? Expression.Call( + formatter.Target is null ? null : Expression.Constant(formatter.Target), + formatter.Method, + Expression.Constant(templateTag), + expVal, + CultureParameter) + : Expression.Call( + finalizer.Target is null ? null : Expression.Constant(finalizer.Target), + finalizer.Method, + Expression.Constant(templateTag), + Expression.Call( + preFormatter.Target is null ? null : Expression.Constant(preFormatter.Target), + preFormatter.Method, + Expression.Constant(templateTag), + expVal, + Expression.Constant(format), + CultureParameter), + CultureParameter); } public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, Func toString) : base(templateTag, propertyGetter) { NameMatcher = new Regex(@$"^<{TagNameForRegex()}>", options); + CreateToObjectExpression = (expVal, _) => expVal; + CreateToStringExpression = (expVal, _) => Expression.Call( toString.Target is null ? null : Expression.Constant(toString.Target), @@ -177,24 +250,43 @@ public class PropertyTagCollection : TagCollection expVal); } - protected override Expression GetTagExpression(string exactName, Dictionary matchData) + protected override Expression GetTagExpression(string exactName, Dictionary matchData, OutputType outputType) { - var formatString = Unescape(matchData.GetValueOrDefault("format")) ?? ""; + var formatString = Unescape(matchData.GetValueOrDefault("format")); + var isReferenceType = !ReturnType.IsValueType; + var isNullableValueType = Nullable.GetUnderlyingType(ReturnType) is not null; - Expression toStringExpression - = !ReturnType.IsValueType - ? Expression.Condition( - Expression.Equal(ValueExpression, Expression.Constant(null)), - Expression.Constant(""), - CreateToStringExpression(ValueExpression, formatString)) - : Nullable.GetUnderlyingType(ReturnType) is null - ? CreateToStringExpression(ValueExpression, formatString) - : Expression.Condition( - Expression.PropertyOrField(ValueExpression, "HasValue"), - CreateToStringExpression(Expression.PropertyOrField(ValueExpression, "Value"), formatString), - Expression.Constant("")); + Expression isNullExpression = isReferenceType + ? Expression.Equal(ValueExpression, Expression.Constant(null)) + : isNullableValueType + ? Expression.Not(Expression.PropertyOrField(ValueExpression, "HasValue")) + : Expression.Constant(false); - return Expression.TryCatch(toStringExpression, Expression.Catch(typeof(Exception), Expression.Constant(exactName))); + // formatters are defined for non-nullable items , and not for ... + var formattableValueExpression = isNullableValueType + ? Expression.PropertyOrField(ValueExpression, "Value") + : ValueExpression; + + if (outputType == OutputType.String) + { + Expression toStringExpression = + Expression.Condition( + isNullExpression, + Expression.Constant(null, typeof(string)), + CreateToStringExpression(formattableValueExpression, formatString)); + + return Expression.TryCatch(toStringExpression, Expression.Catch(typeof(Exception), Expression.Constant(exactName))); + } + else + { + Expression toObjectExpression = + Expression.Condition( + isNullExpression, + Expression.Constant(null, typeof(object)), + Expression.Convert(CreateToObjectExpression(formattableValueExpression, formatString), typeof(object))); + + return Expression.TryCatch(toObjectExpression, Expression.Catch(typeof(Exception), Expression.Constant(null, typeof(object)))); + } } } } diff --git a/Source/FileManager/NamingTemplate/TagBase.cs b/Source/FileManager/NamingTemplate/TagBase.cs index a9d8f1f4..03344b91 100644 --- a/Source/FileManager/NamingTemplate/TagBase.cs +++ b/Source/FileManager/NamingTemplate/TagBase.cs @@ -8,6 +8,12 @@ using System.Text.RegularExpressions; namespace FileManager.NamingTemplate; +internal enum OutputType +{ + String, + Object +} + internal interface IPropertyTag { /// The tag that will be matched in a tag string @@ -23,37 +29,33 @@ internal interface IPropertyTag /// Determine if the template string starts with , and if it does parse the tag to an /// /// Template string + /// Whether to return a string or object expression /// The substring that was matched. /// The that returns the property's value /// True if the starts with this tag. - bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue); + bool StartsWith(string templateString, OutputType outputType, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue); } -internal abstract class TagBase : IPropertyTag +internal abstract class TagBase(ITemplateTag templateTag, Expression propertyExpression) : IPropertyTag { - public ITemplateTag TemplateTag { get; } + public ITemplateTag TemplateTag { get; } = templateTag; public abstract Regex NameMatcher { get; } public Type ReturnType => ValueExpression.Type; - protected Expression ValueExpression { get; } - - protected TagBase(ITemplateTag templateTag, Expression propertyExpression) - { - TemplateTag = templateTag; - ValueExpression = propertyExpression; - } + protected Expression ValueExpression { get; } = propertyExpression; /// Create an that returns the property's value. /// The exact string that was matched to /// Optional extra data parsed from the tag, such as a format string in the match the square brackets, logical negation, and conditional options - protected abstract Expression GetTagExpression(string exactName, Dictionary matchData); + /// Whether to return a string or object expression + protected abstract Expression GetTagExpression(string exactName, Dictionary matchData, OutputType outputType); - public bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue) + public bool StartsWith(string templateString, OutputType outputType, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue) { var match = NameMatcher.Match(templateString); if (match.Success) { exactName = match.Value; - propertyValue = GetTagExpression(exactName, match.Groups.Values.Skip(1).ToDictionary(v => v.Name, v => v)); + propertyValue = GetTagExpression(exactName, match.Groups.Values.Skip(1).ToDictionary(v => v.Name, v => v), outputType); return true; } diff --git a/Source/FileManager/NamingTemplate/TagCollection.cs b/Source/FileManager/NamingTemplate/TagCollection.cs index 066d46b5..a641edeb 100644 --- a/Source/FileManager/NamingTemplate/TagCollection.cs +++ b/Source/FileManager/NamingTemplate/TagCollection.cs @@ -33,16 +33,17 @@ public abstract class TagCollection : IEnumerable /// and if it does parse the tag to an /// /// Template string + /// Whether to return a string or object expression /// The substring that was matched. /// /// The that returns the 's value /// True if the starts with a tag registered in this class. - internal bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, + internal bool StartsWith(string templateString, OutputType outputType, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? propertyValue) { foreach (var p in PropertyTags) { - if (p.StartsWith(templateString, out exactName, out propertyValue)) + if (p.StartsWith(templateString, outputType, out exactName, out propertyValue)) { propertyTag = p; return true; diff --git a/Source/LibationFileManager/Templates/ContributorDto.cs b/Source/LibationFileManager/Templates/ContributorDto.cs index 4be259a1..e7b09847 100644 --- a/Source/LibationFileManager/Templates/ContributorDto.cs +++ b/Source/LibationFileManager/Templates/ContributorDto.cs @@ -31,7 +31,7 @@ public class ContributorDto(string name, string? audibleContributorId) : IFormat if (string.IsNullOrWhiteSpace(format)) return ToString(); - return CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements).Trim(); + return CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements); } private static string RemoveSuffix(string namesString) diff --git a/Source/LibationFileManager/Templates/IListFormat[TList].cs b/Source/LibationFileManager/Templates/IListFormat[TList].cs index b76929e3..2e3c8e4d 100644 --- a/Source/LibationFileManager/Templates/IListFormat[TList].cs +++ b/Source/LibationFileManager/Templates/IListFormat[TList].cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text.RegularExpressions; +using FileManager.NamingTemplate; namespace LibationFileManager.Templates; @@ -20,36 +21,37 @@ internal partial interface IListFormat where TList : IListFormat } } - static IEnumerable FormattedList(string formatString, IEnumerable items, CultureInfo? culture) where T : IFormattable + static IEnumerable FormattedList(string? formatString, IEnumerable items, CultureInfo? culture) where T : IFormattable { + if (formatString is null) return items.Select(n => n.ToString(null, culture)); var format = TList.FormatRegex().Match(formatString).ResolveValue("format"); var separator = SeparatorRegex().Match(formatString).ResolveValue("separator"); var formattedItems = FilteredList(formatString, items).Select(ItemFormatter); - // ReSharper disable PossibleMultipleEnumeration - return separator is null - ? formattedItems - : formattedItems.Any() - ? [Join(separator, formattedItems)] - : []; - // ReSharper restore PossibleMultipleEnumeration + if (separator is null) return formattedItems; + var joined = Join(separator, formattedItems); + return joined is null ? [] : [joined]; string ItemFormatter(T n) => n.ToString(format, culture); } - static string Join(string formatString, IEnumerable items, CultureInfo? culture) where T : IFormattable + static string? Join(IEnumerable? formattedItems, CultureInfo? culture) { - return Join(", ", FormattedList(formatString, items, culture)); + return formattedItems is null ? null : Join(", ", formattedItems); } - private static string Join(string separator, IEnumerable strings) + private static string? Join(string separator, IEnumerable strings) { - return CollapseSpacesRegex().Replace(string.Join(separator, strings), " "); + // ReSharper disable PossibleMultipleEnumeration + return strings.Any() + ? CollapseSpacesAndTrimRegex().Replace(string.Join(separator, strings), "") + : null; + // ReSharper restore PossibleMultipleEnumeration } - // Collapses runs of 2+ spaces into a single space (does NOT touch tabs/newlines). - [GeneratedRegex(@" {2,}")] - private static partial Regex CollapseSpacesRegex(); + // Matches runs of spaces followed by a space as well as runs of spaces at the beginning or the end of a string (does NOT touch tabs/newlines). + [GeneratedRegex(@"^ +| +(?=$| )")] + private static partial Regex CollapseSpacesAndTrimRegex(); static abstract Regex FormatRegex(); diff --git a/Source/LibationFileManager/Templates/NameListFormat.cs b/Source/LibationFileManager/Templates/NameListFormat.cs index 823bbe5c..bc9a43d4 100644 --- a/Source/LibationFileManager/Templates/NameListFormat.cs +++ b/Source/LibationFileManager/Templates/NameListFormat.cs @@ -9,14 +9,17 @@ namespace LibationFileManager.Templates; internal partial class NameListFormat : IListFormat { - public static string Formatter(ITemplateTag _, IEnumerable? names, string formatString, CultureInfo? culture) + public static IEnumerable Formatter(ITemplateTag _, IEnumerable? names, string? formatString, CultureInfo? culture) => names is null - ? string.Empty - : IListFormat.Join(formatString, Sort(names, formatString, ContributorDto.FormatReplacements), culture); + ? [] + : IListFormat.FormattedList(formatString, Sort(names, formatString, ContributorDto.FormatReplacements), culture); - private static IEnumerable Sort(IEnumerable entries, string formatString, Dictionary> formatReplacements) + public static string? Finalizer(ITemplateTag _, IEnumerable? names, CultureInfo? culture) + => IListFormat.Join(names, culture); + + private static IEnumerable Sort(IEnumerable entries, string? formatString, Dictionary> formatReplacements) { - var pattern = SortRegex().Match(formatString).ResolveValue("pattern"); + var pattern = formatString is null ? null : SortRegex().Match(formatString).ResolveValue("pattern"); if (pattern is null) return entries; IOrderedEnumerable? ordered = null; @@ -42,7 +45,7 @@ internal partial class NameListFormat : IListFormat private const string Token = @"(?:[TFMLS]|ID)"; - /// Sort must have at least one of the token labels T, F, M, L, S or ID.Add multiple tokens to sort by multiple fields. Spaces may be used to separate tokens. + /// Sort must have at least one of the token labels T, F, M, L, S or ID. Use lower case for descending direction and add multiple tokens to sort by multiple fields. Spaces may be used to separate tokens. [GeneratedRegex($@"[Ss]ort\(\s*(?i:(?(?:{Token}\s*?)+))\s*\)")] private static partial Regex SortRegex(); diff --git a/Source/LibationFileManager/Templates/SeriesDto.cs b/Source/LibationFileManager/Templates/SeriesDto.cs index 913878a5..cc2dbb95 100644 --- a/Source/LibationFileManager/Templates/SeriesDto.cs +++ b/Source/LibationFileManager/Templates/SeriesDto.cs @@ -22,6 +22,6 @@ public record SeriesDto(string? Name, string? Number, string AudibleSeriesId) : if (string.IsNullOrWhiteSpace(format)) return ToString() ?? string.Empty; - return CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements).Trim(); + return CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements); } } diff --git a/Source/LibationFileManager/Templates/SeriesListFormat.cs b/Source/LibationFileManager/Templates/SeriesListFormat.cs index 2f7fc384..ab5fd5ed 100644 --- a/Source/LibationFileManager/Templates/SeriesListFormat.cs +++ b/Source/LibationFileManager/Templates/SeriesListFormat.cs @@ -9,14 +9,17 @@ namespace LibationFileManager.Templates; internal partial class SeriesListFormat : IListFormat { - public static string Formatter(ITemplateTag _, IEnumerable? series, string formatString, CultureInfo? culture) + public static IEnumerable Formatter(ITemplateTag _, IEnumerable? series, string? formatString, CultureInfo? culture) => series is null - ? string.Empty - : IListFormat.Join(formatString, Sort(series, formatString, SeriesDto.FormatReplacements), culture); + ? [] + : IListFormat.FormattedList(formatString, Sort(series, formatString, SeriesDto.FormatReplacements), culture); - private static IEnumerable Sort(IEnumerable entries, string formatString, Dictionary> formatReplacements) + public static string? Finalizer(ITemplateTag _, IEnumerable? series, CultureInfo? culture) + => IListFormat.Join(series, culture); + + private static IEnumerable Sort(IEnumerable entries, string? formatString, Dictionary> formatReplacements) { - var pattern = SortRegex().Match(formatString).ResolveValue("pattern"); + var pattern = formatString is null ? null : SortRegex().Match(formatString).ResolveValue("pattern"); if (pattern is null) return entries; IOrderedEnumerable? ordered = null; diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index 5cef98c7..fae7ae3a 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -57,4 +57,5 @@ public sealed class TemplateTags : ITemplateTag public static TemplateTags IfPodcastParent { get; } = new TemplateTags("if podcastparent", "Only include if item is a podcast series parent", "<-if podcastparent>", "...<-if podcastparent>"); public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<-if bookseries>", "...<-if bookseries>"); public static TemplateTags Has { get; } = new TemplateTags("has", "Only include if PROPERTY has a value (i.e. not null or empty)", "<-has>", "...<-has>"); + public static TemplateTags Is { get; } = new TemplateTags("is", "Only include if PROPERTY has a value satisfying the check (i.e. string comparison)", "<-is>", "...<-is>"); } diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 6d56a0ba..51a3fc13 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -271,11 +271,11 @@ public abstract class Templates { TemplateTags.TitleShort, lb => GetTitleShort(lb.Title) }, { TemplateTags.AudibleTitle, lb => lb.Title }, { TemplateTags.AudibleSubtitle, lb => lb.Subtitle }, - { TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter }, + { TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter, NameListFormat.Finalizer }, { TemplateTags.FirstAuthor, lb => lb.FirstAuthor, CommonFormatters.FormattableFormatter }, - { TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter }, + { TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter, NameListFormat.Finalizer }, { TemplateTags.FirstNarrator, lb => lb.FirstNarrator, CommonFormatters.FormattableFormatter }, - { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter }, + { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter, SeriesListFormat.Finalizer }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, CommonFormatters.FormattableFormatter }, { TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, CommonFormatters.FormattableFormatter }, { TemplateTags.Language, lb => lb.Language }, @@ -309,7 +309,7 @@ public abstract class Templates { TemplateTags.TitleShort, lb => GetTitleShort(lb.Title) }, { TemplateTags.AudibleTitle, lb => lb.Title }, { TemplateTags.AudibleSubtitle, lb => lb.Subtitle }, - { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter }, + { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter, SeriesListFormat.Finalizer }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, CommonFormatters.FormattableFormatter }, }, new PropertyTagCollection(caseSensitive: true, CommonFormatters.StringFormatter, CommonFormatters.IntegerFormatter, CommonFormatters.DateTimeFormatter) @@ -331,6 +331,7 @@ public abstract class Templates private static readonly ConditionalTagCollection combinedConditionalTags = new() { + { TemplateTags.Is, TryGetValue }, { TemplateTags.Has, TryGetValue, HasValue } }; @@ -342,11 +343,11 @@ public abstract class Templates private static readonly List allPropertyTags = chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).ToList(); - private static string? TryGetValue(ITemplateTag _, CombinedDto dtos, string property, CultureInfo? culture) + private static object? TryGetValue(ITemplateTag _, CombinedDto dtos, string property, CultureInfo? culture) { foreach (var c in allPropertyTags.OfType>()) { - if (c.TryGetValue(property, dtos.LibraryBook, culture, out var value)) + if (c.TryGetObject(property, dtos.LibraryBook, culture, out var value)) return value; } @@ -355,16 +356,22 @@ public abstract class Templates foreach (var c in allPropertyTags.OfType>()) { - if (c.TryGetValue(property, dtos.MultiConvert, culture, out var value)) + if (c.TryGetObject(property, dtos.MultiConvert, culture, out var value)) return value; } return null; } - private static bool HasValue(string? value, CultureInfo? culture) + private static bool HasValue(object? value, CultureInfo? culture) { - return !string.IsNullOrWhiteSpace(value); + var checkItem = (object o, CultureInfo? _) => !string.IsNullOrWhiteSpace(o.ToString()); + return value switch + { + null => false, + IEnumerable e => e.Any(o => checkItem(o, culture)), + _ => checkItem(value, culture) + }; } #endregion diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs index 4303ca00..6556a048 100644 --- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs +++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs @@ -98,16 +98,16 @@ public class GetPortionFilename { new TemplateTag { TagName = "has3" }, TryGetValue, HasValue } }; - private static string? TryGetValue(ITemplateTag templateTag, PropertyClass1 referenceType, string condition, CultureInfo? culture) - => props1.TryGetValue(condition, referenceType, culture, out var value) ? value : null; + private static object? TryGetValue(ITemplateTag templateTag, PropertyClass1 referenceType, string condition, CultureInfo? culture) + => props1.TryGetObject(condition, referenceType, culture, out var value) ? value : null; - private static string? TryGetValue(ITemplateTag templateTag, PropertyClass2 referenceType, string condition, CultureInfo? culture) - => props2.TryGetValue(condition, referenceType, culture, out var value) ? value : null; + private static object? TryGetValue(ITemplateTag templateTag, PropertyClass2 referenceType, string condition, CultureInfo? culture) + => props2.TryGetObject(condition, referenceType, culture, out var value) ? value : null; - private static string? TryGetValue(ITemplateTag templateTag, PropertyClass3 referenceType, string condition, CultureInfo? culture) - => props3.TryGetValue(condition, referenceType, culture, out var value) ? value : null; + 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(string? value, CultureInfo? culture) => !string.IsNullOrWhiteSpace(value); + private static bool HasValue(object? value, 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 0f7a9648..99476a9d 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -272,16 +272,15 @@ namespace TemplatesTests [DataRow(" - ", @"/foo/bar", ".m4b", @"/foo/bar/asin - 06∕09∕22 00:00.m4b", PlatformID.Unix)] public void DateFormat_illegal(string template, string dirFullPath, string extension, string expected, PlatformID platformId) { - if (Environment.OSVersion.Platform == platformId) - { - Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + if (Environment.OSVersion.Platform != platformId) Assert.Inconclusive($"Skipped because OS is not {platformId}."); - fileTemplate.HasWarnings.Should().BeFalse(); - fileTemplate - .GetFilename(GetLibraryBook(), dirFullPath, extension, culture: CultureInfo.InvariantCulture, replacements: Replacements) - .PathWithoutPrefix - .Should().Be(expected); - } + Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + + fileTemplate.HasWarnings.Should().BeFalse(); + fileTemplate + .GetFilename(GetLibraryBook(), dirFullPath, extension, culture: CultureInfo.InvariantCulture, replacements: Replacements) + .PathWithoutPrefix + .Should().Be(expected); } [TestMethod] @@ -399,11 +398,29 @@ namespace TemplatesTests [TestMethod] [DataRow("empty-string<-has>", "")] + [DataRow("empty-string<-has>", "")] + [DataRow("empty-string<-has>", "empty-string")] + [DataRow("empty-string<-has>", "empty-string")] [DataRow("null-string<-has>", "")] + [DataRow("null-string<-has>", "")] + [DataRow("null-string<-has>", "")] + [DataRow("null-string<-has>", "")] [DataRow("null-int<-has>", "")] + [DataRow("null-int<-has>", "")] + [DataRow("null-int<-has>", "")] [DataRow("unknown-tag<-has>", "")] + [DataRow("unknown-tag<-has>", "")] + [DataRow("unknown-tag<-has>", "")] + [DataRow("unknown-tag<-has>", "")] [DataRow("empty-list<-has>", "")] - [DataRow("no-first<-has>", "")] + [DataRow("empty-list<-has>", "")] + [DataRow("empty-list<-has>", "")] + [DataRow("empty-list<-has>", "")] + [DataRow("empty-list<-has>", "")] + [DataRow("no-first<-has>", "")] + [DataRow("no-first<-has>", "")] + [DataRow("no-first<-has>", "")] + [DataRow("no-first<-has>", "")] public void HasValue_on_empty_test(string template, string expected) { var bookDto = GetLibraryBook(); @@ -449,6 +466,16 @@ namespace TemplatesTests [DataRow("true<-has>", "true")] [DataRow("true<-has>", "true")] [DataRow("true<-has>", "true")] + [DataRow("true<-has>", "true")] + [DataRow("true<-has>", "true")] + [DataRow("true<-has>", "true")] + [DataRow("true<-has>", "true")] + [DataRow("true<-has>", "")] + [DataRow("true<-has>", "true")] + [DataRow("true<-has>", "true")] + [DataRow("true<-has>", "true")] + [DataRow("true<-has>", "true")] + [DataRow("true<-has>", "")] public void HasValue_test(string template, string expected) { var bookDto = GetLibraryBook(); @@ -529,15 +556,15 @@ namespace TemplatesTests [DataRow(@"/a/b", @"/a/b/foobar.ext", PlatformID.Unix)] public void IfSeries_empty(string directory, string expected, PlatformID platformId) { - if (Environment.OSVersion.Platform == platformId) - { - Templates.TryGetTemplate("foo<-if series>bar", out var fileTemplate).Should().BeTrue(); + if (Environment.OSVersion.Platform != platformId) + Assert.Inconclusive($"Skipped because OS {platformId}."); - fileTemplate - .GetFilename(GetLibraryBook(), directory, "ext", culture: null, replacements: Replacements) - .PathWithoutPrefix - .Should().Be(expected); - } + Templates.TryGetTemplate("foo<-if series>bar", out var fileTemplate).Should().BeTrue(); + + fileTemplate + .GetFilename(GetLibraryBook(), directory, "ext", culture: null, replacements: Replacements) + .PathWithoutPrefix + .Should().Be(expected); } [TestMethod] @@ -545,14 +572,14 @@ namespace TemplatesTests [DataRow(@"/a/b", @"/a/b/foobar.ext", PlatformID.Unix)] public void IfSeries_no_series(string directory, string expected, PlatformID platformId) { - if (Environment.OSVersion.Platform == platformId) - { - Templates.TryGetTemplate("foo---<-if series>bar", out var fileTemplate).Should().BeTrue(); + if (Environment.OSVersion.Platform != platformId) + Assert.Inconclusive($"Skipped because OS is not {platformId}."); - fileTemplate.GetFilename(GetLibraryBook(null), directory, "ext", culture: null, replacements: Replacements) + Templates.TryGetTemplate("foo---<-if series>bar", out var fileTemplate).Should().BeTrue(); + + fileTemplate.GetFilename(GetLibraryBook(null), directory, "ext", culture: null, replacements: Replacements) .PathWithoutPrefix .Should().Be(expected); - } } [TestMethod] @@ -560,15 +587,15 @@ namespace TemplatesTests [DataRow(@"/a/b", @"/a/b/foo-Sherlock Holmes-asin-bar.ext", PlatformID.Unix)] public void IfSeries_with_series(string directory, string expected, PlatformID platformId) { - if (Environment.OSVersion.Platform == platformId) - { - Templates.TryGetTemplate("foo---<-if series>bar", out var fileTemplate).Should().BeTrue(); + if (Environment.OSVersion.Platform != platformId) + Assert.Inconclusive($"Skipped because OS is not {platformId}."); - fileTemplate - .GetFilename(GetLibraryBook(), directory, "ext", culture: null, replacements: Replacements) - .PathWithoutPrefix - .Should().Be(expected); - } + Templates.TryGetTemplate("foo---<-if series>bar", out var fileTemplate).Should().BeTrue(); + + fileTemplate + .GetFilename(GetLibraryBook(), directory, "ext", culture: null, replacements: Replacements) + .PathWithoutPrefix + .Should().Be(expected); } [TestMethod] @@ -611,7 +638,7 @@ namespace Templates_Other public void Test_trim_to_max_path(string dirFullPath, string template, string expected, PlatformID platformId) { if (Environment.OSVersion.Platform != platformId) - return; + Assert.Inconclusive($"Skipped because OS is not {platformId}."); var sb = new System.Text.StringBuilder(); sb.Append('0', 300); @@ -626,7 +653,7 @@ namespace Templates_Other public void Test_windows_relative_path_too_long(string baseDir, string template) { if (Environment.OSVersion.Platform != PlatformID.Win32NT) - return; + Assert.Inconclusive($"Skipped because OS is not {PlatformID.Win32NT}."); var sb = new System.Text.StringBuilder(); sb.Append('0', 300); @@ -652,8 +679,10 @@ namespace Templates_Other [DataRow(@"/foo/bar/my file.txt", @"/foo/bar/my file - 002 - title.txt", PlatformID.Unix)] public void equiv_GetMultipartFileName(string inStr, string outStr, PlatformID platformId) { - if (Environment.OSVersion.Platform == platformId) - NEW_GetMultipartFileName_FileNamingTemplate(inStr, 2, 100, "title").Should().Be(outStr); + if (Environment.OSVersion.Platform != platformId) + Assert.Inconclusive($"Skipped because OS is not {platformId}."); + + NEW_GetMultipartFileName_FileNamingTemplate(inStr, 2, 100, "title").Should().Be(outStr); } private static string NEW_GetMultipartFileName_FileNamingTemplate(string originalPath, int partsPosition, int partsTotal, string suffix) @@ -682,18 +711,18 @@ namespace Templates_Other [DataRow(@"/foo/.txt", @"/foo/s\l∕a\s∕h\e∕s.txt", PlatformID.Unix)] public void remove_slashes(string inStr, string outStr, PlatformID platformId) { - if (Environment.OSVersion.Platform == platformId) - { - var lbDto = GetLibraryBook(); - lbDto.TitleWithSubtitle = @"s\l/a\s/h\e/s"; + if (Environment.OSVersion.Platform != platformId) + Assert.Inconclusive($"Skipped because OS is not {platformId}."); - var directory = Path.GetDirectoryName(inStr)!; - var fileName = Path.GetFileName(inStr); + var lbDto = GetLibraryBook(); + lbDto.TitleWithSubtitle = @"s\l/a\s/h\e/s"; - Templates.TryGetTemplate<Templates.FileTemplate>(fileName, out var fileNamingTemplate).Should().BeTrue(); + var directory = Path.GetDirectoryName(inStr)!; + var fileName = Path.GetFileName(inStr); - fileNamingTemplate.GetFilename(lbDto, directory, "txt", culture: null, replacements: Replacements).PathWithoutPrefix.Should().Be(outStr); - } + Templates.TryGetTemplate<Templates.FileTemplate>(fileName, out var fileNamingTemplate).Should().BeTrue(); + + fileNamingTemplate.GetFilename(lbDto, directory, "txt", culture: null, replacements: Replacements).PathWithoutPrefix.Should().Be(outStr); } } } @@ -728,13 +757,13 @@ namespace Templates_Folder_Tests [DataRow([@"C:\", new[] { PlatformID.Win32NT }, Templates.ErrorFullPathIsInvalid])] public void Tests(string? template, PlatformID[] platformIds, params string[] expected) { - if (platformIds.Contains(Environment.OSVersion.Platform)) - { - Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate); - var result = folderTemplate.Errors; - result.Should().HaveCount(expected.Length); - result.Should().BeEquivalentTo(expected); - } + if (!platformIds.Contains(Environment.OSVersion.Platform)) + Assert.Inconclusive($"Skipped because OS is not one of {platformIds}."); + + Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate); + var result = folderTemplate.Errors; + result.Should().HaveCount(expected.Length); + result.Should().BeEquivalentTo(expected); } } @@ -761,11 +790,11 @@ namespace Templates_Folder_Tests [DataRow(@"<id>\<title>", true, new[] { PlatformID.Win32NT, PlatformID.Unix })] public void Tests(string template, bool expected, PlatformID[] platformIds) { - if (platformIds.Contains(Environment.OSVersion.Platform)) - { - Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate).Should().BeTrue(); - folderTemplate.IsValid.Should().Be(expected); - } + if (!platformIds.Contains(Environment.OSVersion.Platform)) + Assert.Inconclusive($"Skipped because OS is not one of {platformIds}."); + + Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate).Should().BeTrue(); + folderTemplate.IsValid.Should().Be(expected); } } @@ -875,13 +904,13 @@ namespace Templates_File_Tests private void Tests(string? template, PlatformID platformId, params string[] expected) { - if (Environment.OSVersion.Platform == platformId) - { - Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate); - var result = fileTemplate.Errors; - result.Should().HaveCount(expected.Length); - result.Should().BeEquivalentTo(expected); - } + if (Environment.OSVersion.Platform != platformId) + Assert.Inconclusive($"Skipped because OS is not {platformId}."); + + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate); + var result = fileTemplate.Errors; + result.Should().HaveCount(expected.Length); + result.Should().BeEquivalentTo(expected); } } @@ -954,21 +983,18 @@ namespace Templates_ChapterFile_Tests [TestMethod] [DataRow(@"no tags", null, NamingTemplate.WarningNoTags, Templates.WarningNoChapterNumberTag)] - [DataRow(@"<id>\foo\bar", true, Templates.WarningNoChapterNumberTag)] - [DataRow(@"<id>/foo/bar", false, Templates.WarningNoChapterNumberTag)] + [DataRow(@"<id>\foo\bar", PlatformID.Win32NT, Templates.WarningNoChapterNumberTag)] + [DataRow(@"<id>/foo/bar", PlatformID.Unix, Templates.WarningNoChapterNumberTag)] [DataRow("<chapter count> -- chapter tag but not ch# or ch_#", null, NamingTemplate.WarningNoTags, Templates.WarningNoChapterNumberTag)] - public void Tests(string? template, bool? windows, params string[] expected) + public void Tests(string? template, PlatformID? platformId, params string[] expected) { - if (windows is null - || (windows is true && Environment.OSVersion.Platform is PlatformID.Win32NT) - || (windows is false && Environment.OSVersion.Platform is PlatformID.Unix)) - { + if (platformId is not null && Environment.OSVersion.Platform != platformId) + Assert.Inconclusive($"Skipped because OS is not {platformId}."); - Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate); - var result = chapterFileTemplate.Warnings; - result.Should().HaveCount(expected.Length); - result.Should().BeEquivalentTo(expected); - } + Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate); + var result = chapterFileTemplate.Warnings; + result.Should().HaveCount(expected.Length); + result.Should().BeEquivalentTo(expected); } } @@ -1038,14 +1064,14 @@ namespace Templates_ChapterFile_Tests [DataRow("<ch#>", @"/foo/", "txt", 6, 10, "chap", @"/foo/6.txt", PlatformID.Unix)] public void Tests(string template, string dir, string ext, int pos, int total, string chapter, string expected, PlatformID platformId) { - if (Environment.OSVersion.Platform == platformId) - { - Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterTemplate).Should().BeTrue(); - chapterTemplate - .GetFilename(GetLibraryBook(), new() { OutputFileName = $"xyz.{ext}", PartsPosition = pos, PartsTotal = total, Title = chapter }, dir, ext, culture: null, replacements: Default) - .PathWithoutPrefix - .Should().Be(expected); - } + if (Environment.OSVersion.Platform != platformId) + Assert.Inconclusive($"Skipped because OS is not {platformId}."); + + Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterTemplate).Should().BeTrue(); + chapterTemplate + .GetFilename(GetLibraryBook(), new() { OutputFileName = $"xyz.{ext}", PartsPosition = pos, PartsTotal = total, Title = chapter }, dir, ext, culture: null, replacements: Default) + .PathWithoutPrefix + .Should().Be(expected); } } } diff --git a/docs/features/naming-templates.md b/docs/features/naming-templates.md index 4df76f69..7b61958a 100644 --- a/docs/features/naming-templates.md +++ b/docs/features/naming-templates.md @@ -56,13 +56,16 @@ 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 | -| \<if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is a podcast series parent | Conditional | -| \<has PROPERTY-\>...\<-has\> | Only include if the PROPERTY has a value (i.e. not null or empty) | Conditional | +| 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 | +| \<if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is a podcast series parent | Conditional | +| \<has PROPERTY-\>...\<-has\> | Only include if the PROPERTY has a value (i.e. not null or empty) | Conditional | +| \<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 | **†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked. @@ -70,13 +73,14 @@ 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 | +| 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 | **†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked. @@ -173,3 +177,19 @@ You can use custom formatters to construct customized DateTime string. For more |MM|2-digit month|\<file date[MM]\>|02| |dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14| |HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45| + +### Checks + +| Check-Pattern | Description | Example | +| --------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------- | +| =STRING **†** | Matches if one item is equal to STRING (case ignored) | <has tag[=Tag1]-> | +| !=STRING **†** | Matches if one item is not equal to STRING (case ignored) | <has first author[!=Arthur]-> | +| ~STRING **†** | Matches if one items is matched by the regular expression STRING (case ignored) | <has title[~(\[XYZ\]).*\\1]-> | + +**†** 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. + +#### More complex examples + +This example will truncate the title to 4 characters and check its (trimmed) value to be "the" in any case: + +`<has title[4][=the]>`