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]>`