Allow two step formatting to get checkable values and not only single stringa

Introduce <is-> Tag. Like <has-> but with additional check on content.

Retrieve objects instead of string for conditions

Pass undefined formats as null instead of empty strings
This commit is contained in:
Jo-Be-Co
2026-03-21 00:24:49 +01:00
parent d8ce7cc9b0
commit cd9a070784
16 changed files with 529 additions and 233 deletions

View File

@@ -9,12 +9,29 @@ public static partial class CommonFormatters
{
public const string DefaultDateFormat = "yyyy-MM-dd";
public delegate string PropertyFormatter<in T>(ITemplateTag templateTag, T? value, string formatString, CultureInfo? culture);
public delegate TFormatted? PropertyFormatter<in TProperty, out TFormatted>(ITemplateTag templateTag, TProperty? value, string? formatString, CultureInfo? culture);
public static string StringFormatter(ITemplateTag _, string? value, string formatString, CultureInfo? culture)
public delegate string? PropertyFinalizer<in T>(ITemplateTag templateTag, T? value, CultureInfo? culture);
public static PropertyFinalizer<TProperty> ToPropertyFormatter<TProperty, TPreFormatted>(PropertyFormatter<TProperty, TPreFormatted> preFormatter,
PropertyFinalizer<TPreFormatted> finalizer)
{
return (templateTag, value, culture) => finalizer(templateTag, preFormatter(templateTag, value, null, culture), culture);
}
public static PropertyFinalizer<TPropertyValue> ToFinalizer<TPropertyValue>(PropertyFormatter<TPropertyValue, string> 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<TPropertyValue>(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(@"\{(?<tag>[[A-Z]+|#)(?::(?<format>.*?))?\}", 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);
}

View File

@@ -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<in T>(ITemplateTag templateTag, T value, string condition, CultureInfo? culture);
public delegate object? ValueProvider<in T>(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<TClass>(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive)
public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive)
{
/// <summary>
/// Register a conditional tag.
@@ -45,19 +46,29 @@ public class ConditionalTagCollection<TClass>(bool caseSensitive = true) : TagCo
/// Register a conditional tag.
/// </summary>
/// <param name="templateTag"></param>
/// <param name="valueProvider">A <see cref="ValueProvider{T}"/> to get the condition's <see cref="bool"/> value</param>
/// <param name="conditionEvaluator"></param>
/// <param name="valueProvider">A <see cref="ValueProvider{T}"/> to get the condition's value</param>
/// <param name="conditionEvaluator">A <see cref="ConditionEvaluator"/> to evaluate the condition's value</param>
public void Add(ITemplateTag templateTag, ValueProvider<TClass> valueProvider, ConditionEvaluator conditionEvaluator)
{
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider, conditionEvaluator));
}
private class ConditionalTag : TagBase, IClosingPropertyTag
/// <summary>
/// Register a conditional tag.
/// </summary>
/// <param name="templateTag"></param>
/// <param name="valueProvider">A <see cref="ValueProvider{T}"/> to get the condition's value. The value will be evaluated by a check specified by the tag itself.</param>
public void Add(ITemplateTag templateTag, ValueProvider<TClass> 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<string?, Expression> CreateConditionExpression { get; }
private Func<string?, string?, Expression> CreateConditionExpression { get; }
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
: base(templateTag, conditionExpression)
@@ -65,7 +76,8 @@ public class ConditionalTagCollection<TClass>(bool caseSensitive = true) : TagCo
var tagNameRe = TagNameForRegex();
NameMatcher = new Regex($"^<(?<not>!)?{tagNameRe}->", options);
NameCloseMatcher = new Regex($"^<-{tagNameRe}>", options);
CreateConditionExpression = _ => conditionExpression;
CreateConditionExpression = (_, _) => conditionExpression;
}
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider<TClass> valueProvider, ConditionEvaluator conditionEvaluator)
@@ -84,10 +96,41 @@ public class ConditionalTagCollection<TClass>(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<TClass> valueProvider)
: base(templateTag, Expression.Constant(false))
{
// <property> 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 #
^<(?<not>!)? # tags start with a '<'. Condtionals allow an optional ! captured in <not> to negate the condition
{TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#'
(?:\s+ # the following part is optional. If present it starts with some whitespace
(?<property>.+? # - capture the <property> non greedy so it won't end on whitespace, '[' or '-' (if match is possible)
(?<!\s)) # - don't let <property> end with a whitepace. Otherwise "<tagname [foobar]->" would be matchable.
(?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall be trimmed. So match whitespace first
(?<check> # - capture inner part as <check>
(?:\\. # - '\' 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<TClass> valueProvider, string? property,
ConditionEvaluator conditionEvaluator)
{
@@ -109,6 +152,66 @@ public class ConditionalTagCollection<TClass>(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<object> 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);
}
/// <summary>
/// build a regular expression check which take the <see cref="CultureInfo"/> into account.
/// </summary>
/// <param name="valStr"></param>
/// <returns>check function to validate an object</returns>
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<object> 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<TClass>(bool caseSensitive = true) : TagCo
return false;
}
protected override Expression GetTagExpression(string exactName, Dictionary<string, Group> matchData)
protected override Expression GetTagExpression(string exactName, Dictionary<string, Group> 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
(?<op> # capture operator in <op> and <numop>
~|!=?|=? # - string comparison operators including ~ for regexp. No operator is like =
) \s* # ignore space between operator and value
(?<val> # capture value in <val>
.*? # - 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();
}
}

View File

@@ -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<char> literalChars = new();
List<char> 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;
}

View File

@@ -41,10 +41,51 @@ public class PropertyTagCollection<TClass> : TagCollection
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returns the value the formatted string. If <c>null</c>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PF.PropertyFormatter<TProperty>? formatter = null)
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PF.PropertyFormatter<TProperty, string>? formatter = null)
where TProperty : struct
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
/// <summary>
/// Register a <typeparamref name="TClass"/> property
/// </summary>
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
/// <param name="templateTag"></param>
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returns the value formatted to string. If <c>null</c>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TProperty, string>? formatter = null)
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
/// <summary>
/// Register a nullable value type <typeparamref name="TClass"/> property.
/// </summary>
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
/// <typeparam name="TPreFormatted"></typeparam>
/// <param name="templateTag"></param>
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
/// <param name="preFormatter">A Func used for first filtering and formatting. The result might be a <see cref="string"/></param>
/// <param name="finalizer">This Func assures a string result</param>
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/>
public void Add<TProperty, TPreFormatted>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PF.PropertyFormatter<TProperty, TPreFormatted> preFormatter,
PF.PropertyFinalizer<TPreFormatted> finalizer)
where TProperty : struct
=> RegisterWithPreFormatter(templateTag, propertyGetter, preFormatter, finalizer);
/// <summary>
/// Register a nullable value type <typeparamref name="TClass"/> property.
/// </summary>
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
/// <typeparam name="TPreFormatted"></typeparam>
/// <param name="templateTag"></param>
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
/// <param name="preFormatter">A Func used for first filtering and formatting. The result might be a <see cref="string"/></param>
/// <param name="finalizer">This Func assures a string result</param>
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/>
public void Add<TProperty, TPreFormatted>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TProperty, TPreFormatted> preFormatter,
PF.PropertyFinalizer<TPreFormatted> finalizer)
=> RegisterWithPreFormatter(templateTag, propertyGetter, preFormatter, finalizer);
/// <summary>
/// Register a nullable value type <typeparamref name="TClass"/> property.
/// </summary>
@@ -56,18 +97,6 @@ public class PropertyTagCollection<TClass> : TagCollection
where TProperty : struct
=> RegisterWithToString(templateTag, propertyGetter, toString);
/// <summary>
/// Register a <typeparamref name="TClass"/> property
/// </summary>
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
/// <param name="templateTag"></param>
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returns the value formatted to string. If <c>null</c>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TProperty>? formatter = null)
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
/// <summary>
/// Register a <typeparamref name="TClass"/> property.
/// </summary>
@@ -79,17 +108,33 @@ public class PropertyTagCollection<TClass> : TagCollection
=> RegisterWithToString(templateTag, propertyGetter, toString);
private void RegisterWithFormatter<TProperty, TPropertyValue>
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TPropertyValue>? formatter)
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TPropertyValue, string>? formatter)
{
formatter ??= GetDefaultFormatter<TPropertyValue>();
if (formatter is null)
RegisterWithToString<TProperty, TPropertyValue>(templateTag, propertyGetter, ToStringFunc);
else
RegisterWithFormatters(templateTag, propertyGetter, formatter, PF.StringFinalizer, PF.ToFinalizer(formatter));
}
private void RegisterWithPreFormatter<TProperty, TPropertyValue, TPreFormatted>
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TPropertyValue, TPreFormatted> preFormatter,
PF.PropertyFinalizer<TPreFormatted> finalizer)
{
PF.PropertyFinalizer<TPropertyValue> formatter = PF.ToPropertyFormatter(preFormatter, finalizer);
RegisterWithFormatters(templateTag, propertyGetter, preFormatter, finalizer, formatter);
}
private void RegisterWithFormatters<TProperty, TPropertyValue, TPreFormatted>
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TPropertyValue, TPreFormatted> preFormatter,
PF.PropertyFinalizer<TPreFormatted> finalizer, PF.PropertyFinalizer<TPropertyValue> formatter)
{
ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag));
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
formatter ??= GetDefaultFormatter<TPropertyValue>();
AddPropertyTag(formatter is null
? new PropertyTag<TPropertyValue>(templateTag, Options, expr, ToStringFunc)
: new PropertyTag<TPropertyValue>(templateTag, Options, expr, formatter));
AddPropertyTag(new PropertyTag<TPropertyValue, TPreFormatted>(templateTag, Options, expr, preFormatter, finalizer, formatter));
}
private void RegisterWithToString<TProperty, TPropertyValue>
@@ -99,17 +144,17 @@ public class PropertyTagCollection<TClass> : TagCollection
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, toString));
AddPropertyTag(new PropertyTag<TPropertyValue, string>(templateTag, Options, expr, toString));
}
private static string ToStringFunc<T>(T propertyValue) => propertyValue?.ToString() ?? "";
private PF.PropertyFormatter<T>? GetDefaultFormatter<T>()
private PF.PropertyFormatter<T, string>? GetDefaultFormatter<T>()
{
try
{
var del = _defaultFormatters.FirstOrDefault(kvp => kvp.Key == typeof(T)).Value;
return del is null ? null : Delegate.CreateDelegate(typeof(PF.PropertyFormatter<T>), del.Target, del.Method) as PF.PropertyFormatter<T>;
return del is null ? null : Delegate.CreateDelegate(typeof(PF.PropertyFormatter<T, string>), del.Target, del.Method) as PF.PropertyFormatter<T, string>;
}
catch { return null; }
}
@@ -119,26 +164,29 @@ public class PropertyTagCollection<TClass> : TagCollection
/// </summary>
/// <param name="tagName">Name of the tag value to get</param>
/// <param name="object">The property class from which the tag's value is read</param>
/// <param name="value"><paramref name="tagName"/>'s string value if it is in this collection, otherwise null</param>
/// <param name="culture"></param>
/// <param name="value"><paramref name="tagName"/>'s object value if it is in this collection, otherwise null</param>
/// <returns>True if the <paramref name="tagName"/> is in this collection, otherwise false</returns>
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<Func<TClass, CultureInfo?, string>>(valueExpression, Parameter, CultureParameter).Compile();
var func = Expression.Lambda<Func<TClass, CultureInfo?, object?>>(valueExpression, Parameter, CultureParameter).Compile();
value = func(@object, culture);
return true;
}
private class PropertyTag<TPropertyValue> : TagBase
private class PropertyTag<TPropertyValue, TPreFormatted> : TagBase
{
public override Regex NameMatcher { get; }
private Func<Expression, string, Expression> CreateToStringExpression { get; }
private Func<Expression, string?, Expression> CreateToStringExpression { get; }
private Func<Expression, string?, Expression> CreateToObjectExpression { get; }
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PF.PropertyFormatter<TPropertyValue> formatter)
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PF.PropertyFormatter<TPropertyValue, TPreFormatted> preFormatter,
PF.PropertyFinalizer<TPreFormatted> finalizer, PF.PropertyFinalizer<TPropertyValue> formatter)
: base(templateTag, propertyGetter)
{
NameMatcher = new Regex($"""
@@ -156,20 +204,45 @@ public class PropertyTagCollection<TClass> : 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<TPropertyValue, string> 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<TClass> : TagCollection
expVal);
}
protected override Expression GetTagExpression(string exactName, Dictionary<string, Group> matchData)
protected override Expression GetTagExpression(string exactName, Dictionary<string, Group> 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 <see cref="int"/>, <see cref="DateTime"/> and not for <see cref="int?"/> ...
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))));
}
}
}
}

View File

@@ -8,6 +8,12 @@ using System.Text.RegularExpressions;
namespace FileManager.NamingTemplate;
internal enum OutputType
{
String,
Object
}
internal interface IPropertyTag
{
/// <summary>The tag that will be matched in a tag string</summary>
@@ -23,37 +29,33 @@ internal interface IPropertyTag
/// Determine if the template string starts with <see cref="TemplateTag"/>, and if it does parse the tag to an <see cref="Expression"/>
/// </summary>
/// <param name="templateString">Template string</param>
/// <param name="outputType">Whether to return a string or object expression</param>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
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;
/// <summary>Create an <see cref="Expression"/> that returns the property's value.</summary>
/// <param name="exactName">The exact string that was matched to <see cref="ITemplateTag"/></param>
/// <param name="matchData">Optional extra data parsed from the tag, such as a format string in the match the square brackets, logical negation, and conditional options</param>
protected abstract Expression GetTagExpression(string exactName, Dictionary<string, Group> matchData);
/// <param name="outputType">Whether to return a string or object expression</param>
protected abstract Expression GetTagExpression(string exactName, Dictionary<string, Group> 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;
}

View File

@@ -33,16 +33,17 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
/// and if it does parse the tag to an <see cref="Expression"/>
/// </summary>
/// <param name="templateString">Template string</param>
/// <param name="outputType">Whether to return a string or object expression</param>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyTag"></param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param>
/// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns>
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;

View File

@@ -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)

View File

@@ -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<TList> where TList : IListFormat<TList>
}
}
static IEnumerable<string> FormattedList<T>(string formatString, IEnumerable<T> items, CultureInfo? culture) where T : IFormattable
static IEnumerable<string> FormattedList<T>(string? formatString, IEnumerable<T> 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<T>(string formatString, IEnumerable<T> items, CultureInfo? culture) where T : IFormattable
static string? Join(IEnumerable<string>? formattedItems, CultureInfo? culture)
{
return Join(", ", FormattedList(formatString, items, culture));
return formattedItems is null ? null : Join(", ", formattedItems);
}
private static string Join(string separator, IEnumerable<string> strings)
private static string? Join(string separator, IEnumerable<string> 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();

View File

@@ -9,14 +9,17 @@ namespace LibationFileManager.Templates;
internal partial class NameListFormat : IListFormat<NameListFormat>
{
public static string Formatter(ITemplateTag _, IEnumerable<ContributorDto>? names, string formatString, CultureInfo? culture)
public static IEnumerable<string> Formatter(ITemplateTag _, IEnumerable<ContributorDto>? names, string? formatString, CultureInfo? culture)
=> names is null
? string.Empty
: IListFormat<NameListFormat>.Join(formatString, Sort(names, formatString, ContributorDto.FormatReplacements), culture);
? []
: IListFormat<NameListFormat>.FormattedList(formatString, Sort(names, formatString, ContributorDto.FormatReplacements), culture);
private static IEnumerable<T> Sort<T>(IEnumerable<T> entries, string formatString, Dictionary<string, Func<T, object?>> formatReplacements)
public static string? Finalizer(ITemplateTag _, IEnumerable<string>? names, CultureInfo? culture)
=> IListFormat<NameListFormat>.Join(names, culture);
private static IEnumerable<T> Sort<T>(IEnumerable<T> entries, string? formatString, Dictionary<string, Func<T, object?>> 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<T>? ordered = null;
@@ -42,7 +45,7 @@ internal partial class NameListFormat : IListFormat<NameListFormat>
private const string Token = @"(?:[TFMLS]|ID)";
/// <summary> 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.</summary>
/// <summary> 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.</summary>
[GeneratedRegex($@"[Ss]ort\(\s*(?i:(?<pattern>(?:{Token}\s*?)+))\s*\)")]
private static partial Regex SortRegex();

View File

@@ -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);
}
}

View File

@@ -9,14 +9,17 @@ namespace LibationFileManager.Templates;
internal partial class SeriesListFormat : IListFormat<SeriesListFormat>
{
public static string Formatter(ITemplateTag _, IEnumerable<SeriesDto>? series, string formatString, CultureInfo? culture)
public static IEnumerable<string> Formatter(ITemplateTag _, IEnumerable<SeriesDto>? series, string? formatString, CultureInfo? culture)
=> series is null
? string.Empty
: IListFormat<SeriesListFormat>.Join(formatString, Sort(series, formatString, SeriesDto.FormatReplacements), culture);
? []
: IListFormat<SeriesListFormat>.FormattedList(formatString, Sort(series, formatString, SeriesDto.FormatReplacements), culture);
private static IEnumerable<T> Sort<T>(IEnumerable<T> entries, string formatString, Dictionary<string, Func<T, object?>> formatReplacements)
public static string? Finalizer(ITemplateTag _, IEnumerable<string>? series, CultureInfo? culture)
=> IListFormat<NameListFormat>.Join(series, culture);
private static IEnumerable<T> Sort<T>(IEnumerable<T> entries, string? formatString, Dictionary<string, Func<T, object?>> 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<T>? ordered = null;

View File

@@ -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>", "<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>", "<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>", "<has PROPERTY->...<-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>", "<is PROPERTY->...<-is>");
}

View File

@@ -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<MultiConvertFileProperties>(caseSensitive: true, CommonFormatters.StringFormatter, CommonFormatters.IntegerFormatter, CommonFormatters.DateTimeFormatter)
@@ -331,6 +331,7 @@ public abstract class Templates
private static readonly ConditionalTagCollection<CombinedDto> combinedConditionalTags = new()
{
{ TemplateTags.Is, TryGetValue },
{ TemplateTags.Has, TryGetValue, HasValue }
};
@@ -342,11 +343,11 @@ public abstract class Templates
private static readonly List<TagCollection> 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<PropertyTagCollection<LibraryBookDto>>())
{
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<PropertyTagCollection<MultiConvertFileProperties>>())
{
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<object> e => e.Any(o => checkItem(o, culture)),
_ => checkItem(value, culture)
};
}
#endregion

View File

@@ -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()
{

View File

@@ -272,16 +272,15 @@ namespace TemplatesTests
[DataRow("<id> - <date added[MM/dd/yy HH:mm]>", @"/foo/bar", ".m4b", @"/foo/bar/asin - 060922 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<Templates.FileTemplate>(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<Templates.FileTemplate>(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("<has libation version->empty-string<-has>", "")]
[DataRow("<is libation version[=foobar]->empty-string<-has>", "")]
[DataRow("<is libation version[=]->empty-string<-has>", "empty-string")]
[DataRow("<is libation version[]->empty-string<-has>", "empty-string")]
[DataRow("<has file version->null-string<-has>", "")]
[DataRow("<has file version[=foobar]->null-string<-has>", "")]
[DataRow("<has file version[=]->null-string<-has>", "")]
[DataRow("<has file version[]->null-string<-has>", "")]
[DataRow("<has year->null-int<-has>", "")]
[DataRow("<is year[=]->null-int<-has>", "")]
[DataRow("<is year[]->null-int<-has>", "")]
[DataRow("<has FAKE->unknown-tag<-has>", "")]
[DataRow("<is FAKE[=]->unknown-tag<-has>", "")]
[DataRow("<is FAKE[=foobar]->unknown-tag<-has>", "")]
[DataRow("<is FAKE[]->unknown-tag<-has>", "")]
[DataRow("<has narrator->empty-list<-has>", "")]
[DataRow("<has first narrator->no-first<-has>", "")]
[DataRow("<is narrator[=foobar]->empty-list<-has>", "")]
[DataRow("<is narrator[=]->empty-list<-has>", "")]
[DataRow("<is narrator[~.*]->empty-list<-has>", "")]
[DataRow("<is narrator[]->empty-list<-has>", "")]
[DataRow("<is first narrator->no-first<-has>", "")]
[DataRow("<is first narrator[=foobar]->no-first<-has>", "")]
[DataRow("<is first narrator[=]->no-first<-has>", "")]
[DataRow("<is first narrator[]->no-first<-has>", "")]
public void HasValue_on_empty_test(string template, string expected)
{
var bookDto = GetLibraryBook();
@@ -449,6 +466,16 @@ namespace TemplatesTests
[DataRow("<has ch title->true<-has>", "true")]
[DataRow("<has ch#->true<-has>", "true")]
[DataRow("<has ch# 0->true<-has>", "true")]
[DataRow("<is title[=A Study in Scarlet: An Audible Original Drama]->true<-has>", "true")]
[DataRow("<is title[U][=A STUDY IN SCARLET: AN AUDIBLE ORIGINAL DRAMA]->true<-has>", "true")]
[DataRow("<is title[!=foo]->true<-has>", "true")]
[DataRow("<is title[~A Study.*]->true<-has>", "true")]
[DataRow("<is title[foo]->true<-has>", "")]
[DataRow("<is ch count[=2]->true<-has>", "true")]
[DataRow("<is author[=Arthur Conan Doyle]->true<-has>", "true")]
[DataRow("<is author[format({L})][=Doyle]->true<-has>", "true")]
[DataRow("<is author[format({L})separator(:)][=Doyle:Fry]->true<-has>", "true")]
[DataRow("<is author[=Sherlock]->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<Templates.FileTemplate>("foo<if series-><-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<Templates.FileTemplate>("foo<if series-><-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<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-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<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-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<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-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<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-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/<title>.txt", @"/foo/s\la\sh\es.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);
}
}
}

View File

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