mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-05-10 08:37:55 -04:00
Merge pull request #1795 from Jo-Be-Co/1762_unique_and_count
#1762 add unique() and count() to list properties
This commit is contained in:
@@ -146,10 +146,13 @@ public static partial class CommonFormatters
|
||||
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)
|
||||
=> FloatFormatter(templateTag, value, formatString, culture);
|
||||
public static string IntegerFormatter(ITemplateTag _, int value, string? formatString, CultureInfo? culture)
|
||||
=> _FloatFormatter(value, formatString, culture);
|
||||
|
||||
public static string FloatFormatter(ITemplateTag _, float value, string? formatString, CultureInfo? culture)
|
||||
=> _FloatFormatter(value, formatString, culture);
|
||||
|
||||
public static string _FloatFormatter(float value, string? formatString, CultureInfo? culture)
|
||||
{
|
||||
culture ??= CultureInfo.CurrentCulture;
|
||||
if (!int.TryParse(formatString, out var numDigits) || numDigits <= 0) return value.ToString(formatString, culture);
|
||||
|
||||
291
Source/FileManager/NamingTemplate/CompareCondition.cs
Normal file
291
Source/FileManager/NamingTemplate/CompareCondition.cs
Normal file
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FileManager.NamingTemplate;
|
||||
|
||||
public static partial class CompareCondition
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new();
|
||||
|
||||
private static readonly TimeSpan RegexpCheckTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
public static (object, ConditionEvaluator) GetPredicateAndValue(string exactName, string? checkString)
|
||||
{
|
||||
if (checkString is null)
|
||||
return (string.Empty, (v1, _, _) => v1 switch
|
||||
{
|
||||
null => false,
|
||||
IEnumerable<object> e => e.Any(),
|
||||
_ => !string.IsNullOrWhiteSpace(v1.ToString())
|
||||
});
|
||||
|
||||
var match = GetMatch(exactName, checkString);
|
||||
var valStr = match.UnescapeValue("val");
|
||||
var (evaluator, opGroup) = GetPredicate(exactName, match);
|
||||
|
||||
return (opGroup.Name switch
|
||||
{
|
||||
"num_op" => int.Parse(valStr), // at this stage <val> should have matched digits in CheckRegex
|
||||
"list_op" => new[] { valStr },
|
||||
_ => valStr
|
||||
}, evaluator);
|
||||
}
|
||||
|
||||
public static ConditionEvaluator GetPredicate(string exactName, string? checkString)
|
||||
{
|
||||
return GetPredicate(exactName, GetMatch(exactName, checkString)).Item1;
|
||||
}
|
||||
|
||||
private static Match GetMatch(string exactName, string? checkString)
|
||||
{
|
||||
return CheckRegex().TryMatch(checkString, out var match)
|
||||
? match
|
||||
: throw new ArgumentException($"Invalid check or operator format in conditional tag '{exactName}'. Check string: '{checkString}'");
|
||||
}
|
||||
|
||||
private static (ConditionEvaluator, Group) GetPredicate(string exactName, Match match)
|
||||
{
|
||||
var group = match.Groups["num_op"];
|
||||
if (group.Success)
|
||||
{
|
||||
return (GetPredicateForNumOp(exactName, group.ValueSpan), group);
|
||||
}
|
||||
|
||||
group = match.Groups["list_op"];
|
||||
if (group.Success)
|
||||
{
|
||||
return (GetPredicateForListOp(exactName, group.ValueSpan), group);
|
||||
}
|
||||
|
||||
group = match.Groups["op"];
|
||||
return (GetPredicateForStringOp(exactName, group.ValueSpan), group);
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicateForNumOp(string _, ReadOnlySpan<char> opString)
|
||||
{
|
||||
Func<int, int, CultureInfo?, bool> checkInt = opString switch
|
||||
{
|
||||
"#=" => (v1, v2, _) => v1 == v2,
|
||||
"#!=" or "≠" or "≠" => (v1, v2, _) => v1 != v2,
|
||||
"#>=" or ">=" or "≥" => (v1, v2, _) => v1 >= v2,
|
||||
"#>" or ">" => (v1, v2, _) => v1 > v2,
|
||||
"#<=" or "<=" or "≤" => (v1, v2, _) => v1 <= v2,
|
||||
"#<" or "<" => (v1, v2, _) => v1 < v2,
|
||||
_ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values
|
||||
};
|
||||
return (v1, v2, culture) => ToIntObject(v1) is { } i1 && ToIntObject(v2) is { } i2 && checkInt(i1, i2, culture);
|
||||
}
|
||||
|
||||
private static int? ToIntObject(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => null,
|
||||
IEnumerable<object> e => e.Count(),
|
||||
TimeSpan ts => (int)ts.TotalMinutes,
|
||||
DateTime dt => (int)dt.ToOADate(),
|
||||
string s => s.Length,
|
||||
int i => i,
|
||||
_ => null // language and such shall never match
|
||||
};
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicateForListOp(string _, ReadOnlySpan<char> opString)
|
||||
{
|
||||
var checklist = opString switch
|
||||
{
|
||||
"∋" or ">>" or ":contains:" => Swap<IEnumerable<string>>(IsSubset),
|
||||
"⊇" or ">=>" or ":superset:" => Swap<IEnumerable<string>>(IsSubset),
|
||||
"⊃" or ">->" or ":proper_superset:" => Swap<IEnumerable<string>>(IsProperSubset),
|
||||
"∌" or "!>>" or "∌" or ":not_contains:" => Invert(Swap<IEnumerable<string>>(IsSubset)),
|
||||
"∈" or "<<" or ":in:" => IsSubset,
|
||||
"⊆" or "<=<" or ":subset:" => IsSubset,
|
||||
"⊂" or "<-<" or ":proper_subset:" => IsProperSubset,
|
||||
"∉" or "!<<" or "∉" or ":not_in:" => Invert<IEnumerable<string>>(IsSubset),
|
||||
"⋂" or "&&" or ":overlaps:" => Overlaps,
|
||||
"⋂̸" or "&&!" or "⋂!" or ":disjoint:" => Invert<IEnumerable<string>>(Overlaps),
|
||||
"≡" or "==" or ":equals:" => (e1, e2, culture) =>
|
||||
{
|
||||
var cmp = GetStringComparer(culture);
|
||||
return e1.OrderBy(e => e, cmp).SequenceEqual(e2.OrderBy(e => e, cmp), cmp);
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values
|
||||
};
|
||||
return (v1, v2, culture) => v1 is not null && v2 is not null && checklist(ToEnumerable(v1), ToEnumerable(v2), culture);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ToEnumerable(object value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
IEnumerable<string> e => e,
|
||||
IEnumerable<object> e => e.Select(o => o.ToString() ?? ""),
|
||||
string s => [s],
|
||||
_ => [value.ToString() ?? ""]
|
||||
};
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicateForStringOp(string exactName, ReadOnlySpan<char> opString)
|
||||
{
|
||||
var checkItem = opString switch
|
||||
{
|
||||
"=" or "" => GetStringEqCheck(),
|
||||
"!=" or "!" => Invert(GetStringEqCheck()),
|
||||
"=~" or "~" => GetRegExpCheck(exactName),
|
||||
"!~" => Invert(GetRegExpCheck(exactName)),
|
||||
_ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values
|
||||
};
|
||||
return (v1, v2, culture) => (v1, v2) switch
|
||||
{
|
||||
(null, _) => false,
|
||||
(_, null) => false,
|
||||
(IEnumerable<object> e1, _) => e1.Any(l => checkItem(l, v2, culture)),
|
||||
(_, IEnumerable<object> e2) => e2.Any(r => checkItem(v1, r, culture)),
|
||||
_ => checkItem(v1, v2, culture)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Overlaps(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
|
||||
{
|
||||
var comparer = GetStringComparer(culture);
|
||||
return e1.Any(l => e2.Contains(l, comparer));
|
||||
}
|
||||
|
||||
private static bool IsSubset(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
|
||||
{
|
||||
var comparer = GetStringComparer(culture);
|
||||
return e1.All(l => e2.Contains(l, comparer));
|
||||
}
|
||||
|
||||
private static bool IsProperSubset(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
|
||||
{
|
||||
var comparer = GetStringComparer(culture);
|
||||
// ReSharper disable PossibleMultipleEnumeration
|
||||
return e1.All(l => e2.Contains(l, comparer)) && e2.Any(r => !e1.Contains(r, comparer));
|
||||
// ReSharper restore PossibleMultipleEnumeration
|
||||
}
|
||||
|
||||
private static Func<T, T, CultureInfo?, bool> Invert<T>(Func<T, T, CultureInfo?, bool> condition) => (v1, v2, culture) => !condition(v1, v2, culture);
|
||||
private static Func<T, T, CultureInfo?, bool> Swap<T>(Func<T, T, CultureInfo?, bool> condition) => (v1, v2, culture) => condition(v2, v1, culture);
|
||||
|
||||
private static Func<object, object, CultureInfo?, bool> GetStringEqCheck()
|
||||
{
|
||||
return (v1, v2, culture) => GetStringComparer(culture).Equals(ValueToString(v1, culture), ValueToString(v2, culture));
|
||||
}
|
||||
|
||||
private static string? ValueToString(object value, CultureInfo? culture)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
TimeSpan ts => ts.TotalMinutes.ToString("0", culture),
|
||||
IFormattable f => f.ToString(null, culture),
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static StringComparer GetStringComparer(CultureInfo? culture)
|
||||
{
|
||||
return StringComparer.Create(culture ?? CultureInfo.CurrentCulture, ignoreCase: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a regular expression check. Uses culture-invariant matching for thread-safety and consistency.
|
||||
/// Applies a timeout to prevent regex patterns from causing excessive backtracking and blocking.
|
||||
/// Throws InvalidOperationException if the regex pattern is invalid or evaluation times out.
|
||||
/// </summary>
|
||||
/// <param name="exactName">The full tag string for context in error messages</param>
|
||||
/// <returns>check function to validate an object</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when regex parsing fails or when regex matching times out, indicating faulty user input</exception>
|
||||
private static Func<object, object, CultureInfo?, bool> GetRegExpCheck(string exactName)
|
||||
{
|
||||
return (v1, v2, _) =>
|
||||
{
|
||||
var pattern = v2.ToString() ?? "";
|
||||
var regex = RegexCache.GetOrAdd(pattern, p =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Compile regex with timeout to prevent catastrophic backtracking
|
||||
return new Regex(p,
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, RegexpCheckTimeout);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// If regex compilation fails, throw as faulty user input
|
||||
var errorMessage = BuildErrorMessage(exactName, p, "Invalid regular expression pattern. Correct the pattern and escaping or remove that condition");
|
||||
throw new InvalidOperationException(errorMessage, ex);
|
||||
}
|
||||
});
|
||||
try
|
||||
{
|
||||
// CultureInfo parameter is intentionally ignored (discarded with _).
|
||||
// RegexOptions.CultureInvariant ensures culture-independent matching for predictable behavior.
|
||||
// This is preferred for template conditions because:
|
||||
// 1. Thread-safety: Regex operations are isolated and don't depend on thread-local culture
|
||||
// 2. Consistency: Template matches produce identical results regardless of system locale
|
||||
// 3. Predictability: Rules don't unexpectedly change based on user's OS settings
|
||||
//
|
||||
// Culture-sensitive matching would be problematic in cases like:
|
||||
// - Turkish locale: 'I' has different case folding (I ↔ ı vs. I ↔ i). Pattern "[i-z]" might match Turkish 'ı'.
|
||||
// - German locale: ß might be treated as equivalent to 'ss' during case-insensitive matching.
|
||||
// - Lithuanian locale: 'i' after 'ž' has an accent that affects sorting/matching.
|
||||
//
|
||||
// For naming templates, culture-invariant is the safer default.
|
||||
return regex.IsMatch(v1.ToString() ?? "");
|
||||
}
|
||||
catch (RegexMatchTimeoutException ex)
|
||||
{
|
||||
// Throw if regex evaluation times out, indicating faulty user input (e.g., catastrophic backtracking)
|
||||
var errorMessage = BuildErrorMessage(exactName, pattern, "Regular expression pattern evaluation timed out. Use a simpler pattern or remove that condition");
|
||||
throw new InvalidOperationException(errorMessage, ex);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildErrorMessage(string exactName, string pattern, string errorType)
|
||||
{
|
||||
const int maxMessageLen = 200;
|
||||
|
||||
// Build a full message with the pattern
|
||||
var fullMsg = $"{errorType}: {exactName} -> Pattern: {pattern}";
|
||||
|
||||
// Return a full message if it's within the character limit
|
||||
if (fullMsg.Length <= maxMessageLen) return fullMsg;
|
||||
|
||||
// Keep the error type and as much pattern as possible
|
||||
var maxPatternLen = maxMessageLen - errorType.Length - 23; // Account for ". Pattern starts with: "
|
||||
var trimmedPattern = pattern.Length > maxPatternLen ? pattern[..(maxPatternLen - 3)] + "..." : pattern;
|
||||
return $"{errorType}. Pattern starts with: {trimmedPattern}";
|
||||
}
|
||||
|
||||
[GeneratedRegex("""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
^(?>(?<op>(?<list_op> # anchor at start of line. Capture operator in <op>, <list_op> and <num_op> with every char escapable
|
||||
≡ | == | :equals: # - list operators: ≡ for checking if two lists contain the same items regardless of order
|
||||
| ∌ | !>> | ∌ | :not_contains: # - list operators: ∌ for checking if the first list does not contain any item of the second list
|
||||
| ∋ | >> | :contains: # - list operators: ∋ for checking if the first list contains all items of the second list
|
||||
| ∉ | !<< | ∉ | :not_in: # - list operators: ∉ for checking if the first list is not contained in the second list
|
||||
| ∈ | << | :in: # - list operators: ∈ for checking if the first list is contained in the second list
|
||||
| ⋂̸ | &&! | ⋂! | :disjoint: # - list operators: ⋂̸ for checking if the two lists are disjoint
|
||||
| ⋂ | && | :overlaps: # - list operators: ⋂ for checking if the two lists overlap in at least one item
|
||||
| ⊆ | <=< | :subset: # - list operators: ⊆ for checking if the first list is a subset of the second list (may be equal)
|
||||
| ⊇ | >=> | :superset: # - list operators: ⊇ for checking if the first list is a superset of the second list (may be equal)
|
||||
| ⊂ | <-< | :proper_subset: # - list operators: ⊂ for checking if the first list is a proper subset of the second list (not equal)
|
||||
| ⊃ | >-> | :proper_superset: # - list operators: ⊃ for checking if the first list is a proper superset of the second list (not equal)
|
||||
) | (?<num_op>
|
||||
\#!?= | ≠ | ≠ # - numerical operators: #= #!= ≠
|
||||
| \#[<>]=? # - numerical operators: #<= #>= #< #>
|
||||
| [<>]=? | ≤ | ≥ # - numerical operators: <= >= < > ≤ ≥
|
||||
) | [=!]?~ | !=? | =? # - string comparison operators including ~ for regexp, = and !=. No operator is like =
|
||||
)) \s*? # ignore space between operator and value
|
||||
(?<val>(?(num_op) # capture value in <val>
|
||||
(?:\d)+ # - numerical operators have to be followed by a number
|
||||
| (?:\\.|[^\\])+ ) # - string for comparison. May be empty. Capturing also all whitespace up to the end as this must have been escaped.
|
||||
)?$ # match to the end
|
||||
""")]
|
||||
private static partial Regex CheckRegex();
|
||||
}
|
||||
@@ -76,8 +76,6 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
|
||||
|
||||
private partial class ConditionalTag : TagBase, IClosingPropertyTag
|
||||
{
|
||||
private static readonly TimeSpan RegexpCheckTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
public override Regex NameMatcher { get; }
|
||||
public Regex NameCloseMatcher { get; }
|
||||
|
||||
@@ -140,7 +138,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
|
||||
|
||||
CreateConditionExpression = (exactName, property, checkString, _) =>
|
||||
{
|
||||
var (value, conditionEvaluator) = GetPredicateAndValue(exactName, checkString);
|
||||
var (value, conditionEvaluator) = CompareCondition.GetPredicateAndValue(exactName, checkString);
|
||||
return ConditionEvaluatorCall(conditionEvaluator,
|
||||
ValueProviderCall(templateTag, parameter, valueProvider, property),
|
||||
BuildArgument(value, conditionEvaluator.Method.GetParameters()[1].ParameterType));
|
||||
@@ -176,7 +174,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
|
||||
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
||||
|
||||
CreateConditionExpression = (exactName, property1, checkString, property2)
|
||||
=> ConditionEvaluatorCall(GetPredicate(exactName, checkString),
|
||||
=> ConditionEvaluatorCall(CompareCondition.GetPredicate(exactName, checkString),
|
||||
ValueProviderCall(templateTag, parameter, valueProvider1, property1),
|
||||
ValueProviderCall(templateTag, parameter, valueProvider2, property2));
|
||||
}
|
||||
@@ -208,258 +206,6 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
|
||||
return constant.Type == targetType ? constant : Expression.Convert(constant, targetType);
|
||||
}
|
||||
|
||||
private static (object, ConditionEvaluator) GetPredicateAndValue(string exactName, string? checkString)
|
||||
{
|
||||
if (checkString is null)
|
||||
return (string.Empty, (v1, _, _) => v1 switch
|
||||
{
|
||||
null => false,
|
||||
IEnumerable<object> e => e.Any(),
|
||||
_ => !string.IsNullOrWhiteSpace(v1.ToString())
|
||||
});
|
||||
|
||||
var match = GetMatch(exactName, checkString);
|
||||
var valStr = match.UnescapeValue("val");
|
||||
var (evaluator, opGroup) = GetPredicate(exactName, match);
|
||||
|
||||
return (opGroup.Name switch
|
||||
{
|
||||
"num_op" => int.Parse(valStr!), // at this stage <val> should have matched digits in CheckRegex
|
||||
"list_op" => new[] { valStr ?? string.Empty },
|
||||
_ => valStr ?? string.Empty
|
||||
}, evaluator);
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicate(string exactName, string? checkString)
|
||||
{
|
||||
return GetPredicate(exactName, GetMatch(exactName, checkString)).Item1;
|
||||
}
|
||||
|
||||
private static Match GetMatch(string exactName, string? checkString)
|
||||
{
|
||||
return CheckRegex().TryMatch(checkString, out var match)
|
||||
? match
|
||||
: throw new ArgumentException($"Invalid check or operator format in conditional tag '{exactName}'. Check string: '{checkString}'");
|
||||
}
|
||||
|
||||
private static (ConditionEvaluator, Group) GetPredicate(string exactName, Match match)
|
||||
{
|
||||
var group = match.Groups["num_op"];
|
||||
if (group.Success)
|
||||
{
|
||||
return (GetPredicateForNumOp(exactName, group.ValueSpan), group);
|
||||
}
|
||||
|
||||
group = match.Groups["list_op"];
|
||||
if (group.Success)
|
||||
{
|
||||
return (GetPredicateForListOp(exactName, group.ValueSpan), group);
|
||||
}
|
||||
|
||||
group = match.Groups["op"];
|
||||
return (GetPredicateForStringOp(exactName, group.ValueSpan), group);
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicateForNumOp(string _, ReadOnlySpan<char> opString)
|
||||
{
|
||||
Func<int, int, CultureInfo?, bool> checkInt = opString switch
|
||||
{
|
||||
"#=" => (v1, v2, _) => v1 == v2,
|
||||
"#!=" or "≠" or "≠" => (v1, v2, _) => v1 != v2,
|
||||
"#>=" or ">=" or "≥" => (v1, v2, _) => v1 >= v2,
|
||||
"#>" or ">" => (v1, v2, _) => v1 > v2,
|
||||
"#<=" or "<=" or "≤" => (v1, v2, _) => v1 <= v2,
|
||||
"#<" or "<" => (v1, v2, _) => v1 < v2,
|
||||
_ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values
|
||||
};
|
||||
return (v1, v2, culture) => ToIntObject(v1) is { } i1 && ToIntObject(v2) is { } i2 && checkInt(i1, i2, culture);
|
||||
}
|
||||
|
||||
private static int? ToIntObject(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => null,
|
||||
IEnumerable<object> e => e.Count(),
|
||||
TimeSpan ts => (int)ts.TotalMinutes,
|
||||
DateTime dt => (int)dt.ToOADate(),
|
||||
string s => s.Length,
|
||||
int i => i,
|
||||
_ => null // language and such shall never match
|
||||
};
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicateForListOp(string _, ReadOnlySpan<char> opString)
|
||||
{
|
||||
var checklist = opString switch
|
||||
{
|
||||
"∋" or ">>" or ":contains:" => Swap<IEnumerable<string>>(IsSubset),
|
||||
"⊇" or ">=>" or ":superset:" => Swap<IEnumerable<string>>(IsSubset),
|
||||
"⊃" or ">->" or ":proper_superset:" => Swap<IEnumerable<string>>(IsProperSubset),
|
||||
"∌" or "!>>" or "∌" or ":not_contains:" => Invert(Swap<IEnumerable<string>>(IsSubset)),
|
||||
"∈" or "<<" or ":in:" => IsSubset,
|
||||
"⊆" or "<=<" or ":subset:" => IsSubset,
|
||||
"⊂" or "<-<" or ":proper_subset:" => IsProperSubset,
|
||||
"∉" or "!<<" or "∉" or ":not_in:" => Invert<IEnumerable<string>>(IsSubset),
|
||||
"⋂" or "&&" or ":overlaps:" => Overlaps,
|
||||
"⋂̸" or "&&!" or "⋂!" or ":disjoint:" => Invert<IEnumerable<string>>(Overlaps),
|
||||
"≡" or "==" or ":equals:" => (e1, e2, culture) =>
|
||||
{
|
||||
var cmp = GetStringComparer(culture);
|
||||
return e1.OrderBy(e => e, cmp).SequenceEqual(e2.OrderBy(e => e, cmp), cmp);
|
||||
}
|
||||
,
|
||||
_ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values
|
||||
};
|
||||
return (v1, v2, culture) => v1 is not null && v2 is not null && checklist(ToEnumerable(v1), ToEnumerable(v2), culture);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ToEnumerable(object value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
IEnumerable<string> e => e,
|
||||
IEnumerable<object> e => e.Select(o => o.ToString() ?? ""),
|
||||
string s => [s],
|
||||
_ => [value.ToString() ?? ""]
|
||||
};
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicateForStringOp(string exactName, ReadOnlySpan<char> opString)
|
||||
{
|
||||
var checkItem = opString switch
|
||||
{
|
||||
"=" or "" => GetStringEqCheck(),
|
||||
"!=" or "!" => Invert(GetStringEqCheck()),
|
||||
"=~" or "~" => GetRegExpCheck(exactName),
|
||||
"!~" => Invert(GetRegExpCheck(exactName)),
|
||||
_ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values
|
||||
};
|
||||
return (v1, v2, culture) => (v1, v2) switch
|
||||
{
|
||||
(null, _) => false,
|
||||
(_, null) => false,
|
||||
(IEnumerable<object> e1, _) => e1.Any(l => checkItem(l, v2, culture)),
|
||||
(_, IEnumerable<object> e2) => e2.Any(r => checkItem(v1, r, culture)),
|
||||
_ => checkItem(v1, v2, culture)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Overlaps(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
|
||||
{
|
||||
var comparer = GetStringComparer(culture);
|
||||
return e1.Any(l => e2.Contains(l, comparer));
|
||||
}
|
||||
|
||||
private static bool IsSubset(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
|
||||
{
|
||||
var comparer = GetStringComparer(culture);
|
||||
return e1.All(l => e2.Contains(l, comparer));
|
||||
}
|
||||
|
||||
private static bool IsProperSubset(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
|
||||
{
|
||||
var comparer = GetStringComparer(culture);
|
||||
// ReSharper disable PossibleMultipleEnumeration
|
||||
return e1.All(l => e2.Contains(l, comparer)) && e2.Any(r => !e1.Contains(r, comparer));
|
||||
// ReSharper restore PossibleMultipleEnumeration
|
||||
}
|
||||
|
||||
private static Func<T, T, CultureInfo?, bool> Invert<T>(Func<T, T, CultureInfo?, bool> condition) => (v1, v2, culture) => !condition(v1, v2, culture);
|
||||
private static Func<T, T, CultureInfo?, bool> Swap<T>(Func<T, T, CultureInfo?, bool> condition) => (v1, v2, culture) => condition(v2, v1, culture);
|
||||
|
||||
private static Func<object, object, CultureInfo?, bool> GetStringEqCheck()
|
||||
{
|
||||
return (v1, v2, culture) => GetStringComparer(culture).Equals(ValueToString(v1, culture), ValueToString(v2, culture));
|
||||
}
|
||||
|
||||
private static string? ValueToString(object value, CultureInfo? culture)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
TimeSpan ts => ts.TotalMinutes.ToString("0", culture),
|
||||
IFormattable f => f.ToString(null, culture),
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static StringComparer GetStringComparer(CultureInfo? culture)
|
||||
{
|
||||
return StringComparer.Create(culture ?? CultureInfo.CurrentCulture, ignoreCase: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a regular expression check. Uses culture-invariant matching for thread-safety and consistency.
|
||||
/// Applies a timeout to prevent regex patterns from causing excessive backtracking and blocking.
|
||||
/// Throws InvalidOperationException if the regex pattern is invalid or evaluation times out.
|
||||
/// </summary>
|
||||
/// <param name="exactName">The full tag string for context in error messages</param>
|
||||
/// <returns>check function to validate an object</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when regex parsing fails or when regex matching times out, indicating faulty user input</exception>
|
||||
private static Func<object, object, CultureInfo?, bool> GetRegExpCheck(string exactName)
|
||||
{
|
||||
return (v1, v2, _) =>
|
||||
{
|
||||
var pattern = v2.ToString() ?? "";
|
||||
var regex = RegexCache.GetOrAdd(pattern, p =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Compile regex with timeout to prevent catastrophic backtracking
|
||||
return new Regex(p,
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled,
|
||||
RegexpCheckTimeout);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// If regex compilation fails, throw as faulty user input
|
||||
var errorMessage = BuildErrorMessage(exactName, p, "Invalid regular expression pattern. Correct the pattern and escaping or remove that condition");
|
||||
throw new InvalidOperationException(errorMessage, ex);
|
||||
}
|
||||
});
|
||||
try
|
||||
{
|
||||
// CultureInfo parameter is intentionally ignored (discarded with _).
|
||||
// RegexOptions.CultureInvariant ensures culture-independent matching for predictable behavior.
|
||||
// This is preferred for template conditions because:
|
||||
// 1. Thread-safety: Regex operations are isolated and don't depend on thread-local culture
|
||||
// 2. Consistency: Template matches produce identical results regardless of system locale
|
||||
// 3. Predictability: Rules don't unexpectedly change based on user's OS settings
|
||||
//
|
||||
// Culture-sensitive matching would be problematic in cases like:
|
||||
// - Turkish locale: 'I' has different case folding (I ↔ ı vs. I ↔ i). Pattern "[i-z]" might match Turkish 'ı'.
|
||||
// - German locale: ß might be treated as equivalent to 'ss' during case-insensitive matching.
|
||||
// - Lithuanian locale: 'i' after 'ž' has an accent that affects sorting/matching.
|
||||
//
|
||||
// For naming templates, culture-invariant is the safer default.
|
||||
return regex.IsMatch(v1.ToString() ?? "");
|
||||
}
|
||||
catch (RegexMatchTimeoutException ex)
|
||||
{
|
||||
// Throw if regex evaluation times out, indicating faulty user input (e.g., catastrophic backtracking)
|
||||
var errorMessage = BuildErrorMessage(exactName, pattern, "Regular expression pattern evaluation timed out. Use a simpler pattern or remove that condition");
|
||||
throw new InvalidOperationException(errorMessage, ex);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildErrorMessage(string exactName, string pattern, string errorType)
|
||||
{
|
||||
const int maxMessageLen = 200;
|
||||
|
||||
// Build a full message with the pattern
|
||||
var fullMsg = $"{errorType}: {exactName} -> Pattern: {pattern}";
|
||||
|
||||
// Return a full message if it's within the character limit
|
||||
if (fullMsg.Length <= maxMessageLen) return fullMsg;
|
||||
|
||||
// Keep the error type and as much pattern as possible
|
||||
var maxPatternLen = maxMessageLen - errorType.Length - 23; // Account for ". Pattern starts with: "
|
||||
var trimmedPattern = pattern.Length > maxPatternLen ? pattern[..(maxPatternLen - 3)] + "..." : pattern;
|
||||
return $"{errorType}. Pattern starts with: {trimmedPattern}";
|
||||
|
||||
}
|
||||
|
||||
// without any special check, only the existence of the property is checked. Strings need to be non-empty.
|
||||
|
||||
public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag)
|
||||
@@ -486,32 +232,5 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
|
||||
matchData.GetValueOrDefault("second_property")?.ValueOrNull());
|
||||
return matchData["not"].Success ? Expression.Not(getBool) : getBool;
|
||||
}
|
||||
|
||||
[GeneratedRegex("""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
^(?>(?<op>(?<list_op> # anchor at start of line. Capture operator in <op>, <list_op> and <num_op> with every char escapable
|
||||
≡ | == | :equals: # - list operators: ≡ for checking if two lists contain the same items regardless of order
|
||||
| ∌ | !>> | ∌ | :not_contains: # - list operators: ∌ for checking if the first list does not contain any item of the second list
|
||||
| ∋ | >> | :contains: # - list operators: ∋ for checking if the first list contains all items of the second list
|
||||
| ∉ | !<< | ∉ | :not_in: # - list operators: ∉ for checking if the first list is not contained in the second list
|
||||
| ∈ | << | :in: # - list operators: ∈ for checking if the first list is contained in the second list
|
||||
| ⋂̸ | &&! | ⋂! | :disjoint: # - list operators: ⋂̸ for checking if the two lists are disjoint
|
||||
| ⋂ | && | :overlaps: # - list operators: ⋂ for checking if the two lists overlap in at least one item
|
||||
| ⊆ | <=< | :subset: # - list operators: ⊆ for checking if the first list is a subset of the second list (may be equal)
|
||||
| ⊇ | >=> | :superset: # - list operators: ⊇ for checking if the first list is a superset of the second list (may be equal)
|
||||
| ⊂ | <-< | :proper_subset: # - list operators: ⊂ for checking if the first list is a proper subset of the second list (not equal)
|
||||
| ⊃ | >-> | :proper_superset: # - list operators: ⊃ for checking if the first list is a proper superset of the second list (not equal)
|
||||
) | (?<num_op>
|
||||
\#!?= | ≠ | ≠ # - numerical operators: #= #!= ≠
|
||||
| \#[<>]=? # - numerical operators: #<= #>= #< #>
|
||||
| [<>]=? | ≤ | ≥ # - numerical operators: <= >= < > ≤ ≥
|
||||
) | [=!]?~ | !=? | =? # - string comparison operators including ~ for regexp, = and !=. No operator is like =
|
||||
)) \s*? # ignore space between operator and value
|
||||
(?<val>(?(num_op) # capture value in <val>
|
||||
(?:\d)+ # - numerical operators have to be followed by a number
|
||||
| (?:\\.|[^\\])+ ) # - string for comparison. May be empty. Capturing also all whitespace up to the end as this must have been escaped.
|
||||
)?$ # match to the end
|
||||
""")]
|
||||
private static partial Regex CheckRegex();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
@@ -13,8 +12,6 @@ namespace FileManager.NamingTemplate;
|
||||
/// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary>
|
||||
public abstract class TagCollection : IEnumerable<ITemplateTag>
|
||||
{
|
||||
protected static readonly ConcurrentDictionary<string, Regex> RegexCache = new();
|
||||
|
||||
/// <summary>The <see cref="ITemplateTag"/>s registered with this <see cref="TagCollection"/> </summary>
|
||||
public IEnumerator<ITemplateTag> GetEnumerator() => PropertyTags.Select(p => p.TemplateTag).GetEnumerator();
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ public class ReplacementCharacters
|
||||
|
||||
if (CharIsPathInvalid(c)
|
||||
|| invalidSlashes.Contains(c)
|
||||
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ )
|
||||
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that the user wants. */)
|
||||
{
|
||||
char preceding = i > 0 ? fileName[i - 1] : default;
|
||||
char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default;
|
||||
@@ -265,7 +265,7 @@ public class ReplacementCharacters
|
||||
if (
|
||||
(
|
||||
CharIsPathInvalid(c)
|
||||
|| ( // Replace any other legal characters that they user wants.
|
||||
|| ( // Replace any other legal characters that the user wants.
|
||||
c != Path.DirectorySeparatorChar
|
||||
&& c != Path.AltDirectorySeparatorChar
|
||||
&& Replacements.Any(r => r.CharacterToReplace == c)
|
||||
|
||||
@@ -10,6 +10,8 @@ public class ContributorDto(string name, string? audibleContributorId) : IFormat
|
||||
private HumanName HumanName { get; } = new(RemoveSuffix(name), Prefer.FirstOverPrefix);
|
||||
private string? AudibleContributorId { get; } = audibleContributorId;
|
||||
|
||||
private const string DefaultFormat = "{T} {F} {M} {L} {S}";
|
||||
|
||||
public static readonly Dictionary<string, Func<ContributorDto, object?>> FormatReplacements = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Single-word names parse as first names. Use it as last name.
|
||||
@@ -23,12 +25,10 @@ public class ContributorDto(string name, string? audibleContributorId) : IFormat
|
||||
{ "ID", dto => dto.AudibleContributorId },
|
||||
};
|
||||
|
||||
public override string ToString() => ToString("{T} {F} {M} {L} {S}", null);
|
||||
public override string ToString() => ToString(null, null);
|
||||
|
||||
public string ToString(string? format, IFormatProvider? provider)
|
||||
=> string.IsNullOrWhiteSpace(format)
|
||||
? ToString()
|
||||
: CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements);
|
||||
=> CommonFormatters.TemplateStringFormatter(this, string.IsNullOrWhiteSpace(format) ? DefaultFormat : format, provider, FormatReplacements);
|
||||
|
||||
private static string RemoveSuffix(string namesString)
|
||||
{
|
||||
|
||||
@@ -3,22 +3,34 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using static FileManager.NamingTemplate.RegExpExtensions;
|
||||
using FileManager.NamingTemplate;
|
||||
|
||||
namespace LibationFileManager.Templates;
|
||||
|
||||
internal partial interface IListFormat<TList> where TList : IListFormat<TList>
|
||||
{
|
||||
static IEnumerable<T> FilteredList<T>(string formatString, IEnumerable<T> items)
|
||||
static IEnumerable<T> FilteredList<T>(string formatString, IEnumerable<T> items, CultureInfo? culture) where T : IFormattable
|
||||
{
|
||||
return Max(formatString, Slice(formatString, items));
|
||||
return Max(formatString, Slice(formatString, Unique(formatString, items, culture)));
|
||||
|
||||
static StringComparer GetStringComparer(CultureInfo? culture)
|
||||
{
|
||||
return StringComparer.Create(culture ?? CultureInfo.CurrentCulture, ignoreCase: true);
|
||||
}
|
||||
|
||||
static IEnumerable<T> Unique(string formatString, IEnumerable<T> items, CultureInfo? culture)
|
||||
{
|
||||
return UniqueRegex().TryMatch(formatString, out var uniqueMatch)
|
||||
? items.DistinctBy(n => n.ToString(uniqueMatch.ResolveValue("format"), culture), GetStringComparer(culture))
|
||||
: items;
|
||||
}
|
||||
|
||||
static IEnumerable<T> Slice(string formatString, IEnumerable<T> items)
|
||||
{
|
||||
if (!SliceRegex().TryMatch(formatString, out var sliceMatch)) return items;
|
||||
|
||||
int.TryParse(sliceMatch.Groups["first"].ValueSpan, out var first);
|
||||
int.TryParse(sliceMatch.Groups["last"].ValueSpan, out var last);
|
||||
sliceMatch.TryParseInt("first", out var first);
|
||||
sliceMatch.TryParseInt("last", out var last);
|
||||
if (!sliceMatch.Groups["op"].Success) last = first;
|
||||
|
||||
if (last > 0)
|
||||
@@ -52,11 +64,20 @@ internal partial interface IListFormat<TList> where TList : IListFormat<TList>
|
||||
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).UnescapeValueOrNull("separator");
|
||||
var formattedItems = FilteredList(formatString, items).Select(ItemFormatter);
|
||||
var filteredList = FilteredList(formatString, items, culture);
|
||||
|
||||
if (CountRegex().TryMatch(formatString, out var countMatch))
|
||||
{
|
||||
var count = filteredList.Count();
|
||||
return count == 0 ? [] : [CommonFormatters._FloatFormatter(count, countMatch.ResolveValue("format"), culture)];
|
||||
}
|
||||
|
||||
var format = TList.FormatRegex().Match(formatString).ResolveValue("format");
|
||||
var formattedItems = filteredList.Select(ItemFormatter);
|
||||
var separator = SeparatorRegex().Match(formatString).UnescapeValueOrNull("separator");
|
||||
if (separator is null)
|
||||
return formattedItems;
|
||||
|
||||
if (separator is null) return formattedItems;
|
||||
var joined = Join(separator, formattedItems);
|
||||
return joined is null ? [] : [joined];
|
||||
|
||||
@@ -98,4 +119,12 @@ internal partial interface IListFormat<TList> where TList : IListFormat<TList>
|
||||
/// <summary> Separator can be anything </summary>
|
||||
[GeneratedRegex("""[Ss]eparator\((?<separator>(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)\)""")]
|
||||
private static partial Regex SeparatorRegex();
|
||||
|
||||
/// <summary> Count will substitute all list members with a single number equal to there count </summary>
|
||||
[GeneratedRegex("""[Cc]ount\((?<format>(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)\)""")]
|
||||
private static partial Regex CountRegex();
|
||||
|
||||
/// <summary> Unique will shrink the list to unique members after applying format to them </summary>
|
||||
[GeneratedRegex("""[Uu]nique\((?<format>(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)\)""")]
|
||||
private static partial Regex UniqueRegex();
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ namespace TemplatesTests
|
||||
[TestMethod]
|
||||
[DataRow("<narrator>", "")]
|
||||
[DataRow("<narrator[format({L})]>", "")]
|
||||
[DataRow("<narrator[count()]>", "")]
|
||||
[DataRow("<first narrator>", "")]
|
||||
[DataRow("<file version>", "")]
|
||||
[DataRow("<libation version>", "")]
|
||||
@@ -398,6 +399,16 @@ namespace TemplatesTests
|
||||
[DataRow("<author[slice(-3..-2)]>", "Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[sort(LF) slice(4..5)]>", "Charles E. Gannon, Emma Gannon")]
|
||||
[DataRow("<author[sort(Lf) slice(4..5)]>", "Emma Gannon, Charles E. Gannon")]
|
||||
[DataRow("<author[unique({L:1}) format({L})]>", "Browne, Gannon, Fetherolf, Montgomery, Van Doren")]
|
||||
[DataRow("<author[count()]>", "7")]
|
||||
[DataRow("<author[max(42) count()]>", "7")]
|
||||
[DataRow("<author[max(2) count()]>", "2")]
|
||||
[DataRow("<author[count(000)]>", "007")]
|
||||
[DataRow("<author[sort(Lf) count(000)]>", "007")]
|
||||
[DataRow("<author[separator(:) count(000)]>", "007")]
|
||||
[DataRow("<author[format({L}) count(000)]>", "007")]
|
||||
[DataRow("<author[format({L}) slice(1..2)]> <author[slice(3..) count(' and '0' more')]>", "Browne, Gannon and 5 more")]
|
||||
[DataRow("<author[format({L}) slice(1..7)]> <author[slice(8..) count(' and '0' more')]>", "Browne, Gannon, Fetherolf, Montgomery, Bon Jovi, Van Doren, Gannon ")]
|
||||
[DataRow("<author[format({L}, {F})]>", "Browne, Jill, Gannon, Charles, Fetherolf, Christopher, Montgomery, Lucy, Bon Jovi, Jon, Van Doren, Paul, Gannon, Emma")]
|
||||
[DataRow("<author[format({L}, {F} {ID})]>", "Browne, Jill B1, Gannon, Charles B2, Fetherolf, Christopher B3, Montgomery, Lucy B4, Bon Jovi, Jon B5, Van Doren, Paul B6, Gannon, Emma B7")]
|
||||
[DataRow("<author[format({ID})]>", "B1, B2, B3, B4, B5, B6, B7")]
|
||||
@@ -708,6 +719,9 @@ namespace TemplatesTests
|
||||
[DataRow("<series>", "Series A, Series B, Series C, Series D")]
|
||||
[DataRow("<series[]>", "Series A, Series B, Series C, Series D")]
|
||||
[DataRow("<series[slice(2..3)]>", "Series B, Series C")]
|
||||
[DataRow("<series[count(00)]>", "04")]
|
||||
[DataRow("<series[unique({N:6}) sort(n)]>", "Series D")]
|
||||
[DataRow("<series[unique({N:6}) count()]>", "1")]
|
||||
[DataRow("<series[max(1)]>", "Series A")]
|
||||
[DataRow("<series[max(2)]>", "Series A, Series B")]
|
||||
[DataRow("<series[max(3)]>", "Series A, Series B, Series C")]
|
||||
@@ -828,6 +842,8 @@ namespace TemplatesTests
|
||||
[DataRow("<audibletitle [u]>", "I", "en-US", "i")]
|
||||
[DataRow("<audibletitle [l]>", "ı", "tr-TR", "I")]
|
||||
[DataRow("<audibletitle [u]>", "İ", "tr-TR", "i")]
|
||||
[DataRow("<author>", "Isaac Asimov", "tr-TR", "any")]
|
||||
[DataRow("<author[format({F:L} {L:U})]>", "ısaac ASİMOV", "tr-TR", "any")]
|
||||
[DataRow(@"<minutes[D,DDD.DDE-0\-H,HHH.HH\-#,##M.##]>", "8.573,30E1-0.021,00-9", "es-ES", "any")]
|
||||
[DataRow(@"<minutes[D,DDD.DDE-0\-H,HHH.HH\-#,##M.##]>", "8,573.30E1-0,021.00-9", "en-AU", "any")]
|
||||
[DataRow("<samplerate[#,##0'Hz ']>", "44,100Hz ", "en-CA", "any")]
|
||||
@@ -838,6 +854,7 @@ namespace TemplatesTests
|
||||
var bookDto = Shared.GetLibraryBook();
|
||||
bookDto.Title = title;
|
||||
bookDto.LengthInMinutes = TimeSpan.FromMinutes(123456789);
|
||||
bookDto.Authors = [new("Isaac Asimov", "B00IA42MOV")];
|
||||
var culture = new CultureInfo(cultureName);
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
@@ -853,6 +870,9 @@ namespace TemplatesTests
|
||||
[DataRow("<tag [format({S:u})]>", "TAG1, TAG2, TAG3")]
|
||||
[DataRow("<tag[format({S:l})]>", "tag1, tag2, tag3")]
|
||||
[DataRow("<tag[format(Tag: {S})]>", "Tag: Tag1, Tag: Tag2, Tag: Tag3")]
|
||||
[DataRow("<tag[count(00)]>", "03")]
|
||||
[DataRow("<tag[unique({S:3}) sort(s)]>", "Tag3")]
|
||||
[DataRow("<tag[unique({S:3}) count()]>", "1")]
|
||||
[DataRow("<tag [max(1)]>", "Tag1")]
|
||||
[DataRow("<tag [slice(2..)]>", "Tag2, Tag3")]
|
||||
[DataRow("<tag[sort(s)]>", "Tag3, Tag2, Tag1")]
|
||||
|
||||
@@ -131,20 +131,24 @@ Text formatting can change length and case of the text. Use \<#\>, \<#\>\<case\>
|
||||
|
||||
### Text List Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
|----------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |--------------------------------------------- | ---------------------------------------------|
|
||||
| separator() | Specify the text used to join<br>multiple entries.<br><br>Default is ", " | `<tag[separator(_)]>` | Tag1_Tag2_Tag3_Tag4_Tag5 |
|
||||
| format(\{S\}) **†** | Formats the entries by placing their values into the specified template.<br>Use \{S:[Text formatters](#text-formatters)\} to place the entry and optionally apply a format. | `<tag[format(Tag={S:l})`<br>`separator(;)]>` | Tag=tag1;Tag=tag2;Tag=tag3;Tag=tag4;Tag=tag5 |
|
||||
| sort(S) | Sorts the elements by their value.<br><br>*Sorting direction:*<br>uppercase = ascending<br>lowercase = descending<br><br>Default is unsorted | `<tag[sort(s)`<br>`separator(;)]>` | Tag5;Tag4;Tag3;Tag2;Tag1 |
|
||||
| max(#) | Only use the first # of entries | `<tag[max(1)]>` | Tag1 |
|
||||
| slice(#) | Only use the nth entry of the list | `<tag[slice(2)]>` | Tag2 |
|
||||
| slice(#..) | Only use entries of the list starting from # | `<tag[slice(2..)]>` | Tag2, Tag3, Tag4, Tag5 |
|
||||
| slice(..#) | Like max(#). Only use the first # of entries | `<tag[slice(..1)]>` | Tag1 |
|
||||
| slice(#..#) | Only use entries of the list starting from # and ending at # (inclusive) | `<tag[slice(2..4)]>` | Tag2, Tag3, Tag4 |
|
||||
| slice(-#..-#) | Numbers may be specified negative. In that case positions ar counted from the end with -1 pointing at the last member | `<tag[slice(-3..-2)]>` | Tag3, Tag4 |
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|----------------------------------------------|
|
||||
| separator() | Specify the text used to join<br>multiple entries.<br><br>Default is ", " | `<tag[separator(_)]>` | Tag1_Tag2_Tag3_Tag4_Tag5 |
|
||||
| format(\{S\}) **†** | Formats the entries by placing their values into the specified template.<br>Use \{S:[Text formatters](#text-formatters)\} to place the entry and optionally apply a format. | `<tag[format(Tag={S:l})`<br>`separator(;)]>` | Tag=tag1;Tag=tag2;Tag=tag3;Tag=tag4;Tag=tag5 |
|
||||
| unique(FMT) **†** | Reduce list members to a unique set. Entries are compared to each other after applying the given format. Duplicate entries (after format is applied) are removed, keeping the first occurrence. | `<tag[unique()]>`<hr>`<tag[unique({S:1L})`<br>`separator(;)]>` | Tag1, Tag2, Tag3<hr>tag1 |
|
||||
| sort(S) | Sorts the elements by their value.<br><br>*Sorting direction:*<br>uppercase = ascending<br>lowercase = descending<br><br>Default is unsorted | `<tag[sort(s)`<br>`separator(;)]>` | Tag5;Tag4;Tag3;Tag2;Tag1 |
|
||||
| max(#) | Only use the first # of entries | `<tag[max(1)]>` | Tag1 |
|
||||
| slice(#) | Only use the nth entry of the list | `<tag[slice(2)]>` | Tag2 |
|
||||
| slice(#..) | Only use entries of the list starting from # | `<tag[slice(2..)]>` | Tag2, Tag3, Tag4, Tag5 |
|
||||
| slice(..#) | Like max(#). Only use the first # of entries | `<tag[slice(..1)]>` | Tag1 |
|
||||
| slice(#..#) | Only use entries of the list starting from # and ending at # (inclusive) | `<tag[slice(2..4)]>` | Tag2, Tag3, Tag4 |
|
||||
| slice(-#..-#) | Numbers may be specified negative. In that case positions ar counted from the end with -1 pointing at the last member | `<tag[slice(-3..-2)]>` | Tag3, Tag4 |
|
||||
| count(FMT) **‡** | Instead of returning some or all members of the list, print out the number of entries using the specified [format](#number-formatters). | `<tag[count()]>`<hr>`<tag[count(00)]>` | 5<hr>05 |
|
||||
|
||||
**†** For further information on format templates, please refer to the [Format templates](#format-templates) section.
|
||||
|
||||
**‡** When `count(FMT)` is used, only the number is output in the specified [number format](#number-formatters). Any other output-related specifications from `separator()` or `format()` are irrelevant here and are ignored.
|
||||
|
||||
### Series Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
@@ -156,15 +160,19 @@ Text formatting can change length and case of the text. Use \<#\>, \<#\>\<case\>
|
||||
### Series List Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
|---------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
|---------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |-------------------------------------------------------------------------------------------| ------------------------------------------------------------------------------------------------------------------- |
|
||||
| separator() | Specify the text used to join<br>multiple series names.<br><br>Default is ", " | `<series[separator(; )]>` | Sherlock Holmes; Some Other Series |
|
||||
| format(\{N \| # \| ID\}) **†** | Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above. | `<series[format({N}, {#})`<br>`separator(; )]>`<hr>`<series[format({ID}-{N}, {#:00.0})]>` | Sherlock Holmes, 1-6; Book Collection, 1<hr>B08376S3R2-Sherlock Holmes, 01.0-06.0, B000000000-Book Collection, 01.0 |
|
||||
| unique(FMT) **†** | Reduce list members to a unique set. Entries are compared to each other after applying the given format. Duplicate entries (after format is applied) are removed, keeping the first occurrence. | `<series[unique()]>`<hr>`<series[unique({N:L})`<br>`separator(; )]>` | Sherlock Holmes; Some Other Series<hr>sherlock holmes; some other series |
|
||||
| sort(N \| # \| ID) | Sorts the series by name, number or ID.<br><br>These terms define the primary, secondary, tertiary, … sorting order.<br>You may combine multiple terms in sequence to specify multi‑level sorting.<br><br>*Sorting direction:*<br>uppercase = ascending<br>lowercase = descending<br><br>Default is unsorted | `<series[sort(N)`<br>`separator(; )]>` | Book Collection, 1; Sherlock Holmes, 1-6 |
|
||||
| max(#) | Only use the first # of series | `<series[max(1)]>` | Sherlock Holmes |
|
||||
| slice(#..#) | Only use entries of the series list starting from # and ending at # (inclusive)<br><br>See [Text List Formatter Usage](#Text-List-Formatters) above for details on all the variants of `slice()` | `<series[slice(..-2)]>` | Sherlock Holmes |
|
||||
| count(FMT) **‡** | Instead of returning some or all members of the list, print out the number of series using the specified [format](#number-formatters). | `<series[count()]>`<hr>`<series[count(00)]>` | 2<hr>02 |
|
||||
|
||||
**†** For further information on format templates, please refer to the [Format templates](#format-templates) section.
|
||||
|
||||
**‡** When `count(FMT)` is used, only the number is output in the specified [number format](#number-formatters). Any other output-related specifications from `separator()` or `format()` are irrelevant here and are ignored.
|
||||
|
||||
### Name Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
@@ -176,15 +184,19 @@ Text formatting can change length and case of the text. Use \<#\>, \<#\>\<case\>
|
||||
### Name List Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
|------------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| separator() | Specify the text used to join<br>multiple people's names.<br><br>Default is ", " | `<author[separator(; )]>` | Arthur Conan Doyle; Stephen Fry |
|
||||
| format(\{T \| F \| M \| L \| S \| ID\}) **†** | Formats the human name using<br>the name part tags.<br>See [Name Formatter Usage](#name-formatters) above. | `<author[format({L:u}, {F})`<br>`separator(; )]>`<hr>`<author[format({L}, {F:1}.`<br>`_{ID}_) separator(; )]>` | DOYLE, Arthur; FRY, Stephen<hr>Doyle, A. \_B000AQ43GQ\_;<br>Fry, S. \_B000APAGVS\_ |
|
||||
| sort(T \| F \| M \| L \| S \| ID) | Sorts the names by title,<br> first, middle, or last name,<br>suffix or Audible Contributor ID<br><br>These terms define the primary, secondary, tertiary, … sorting order.<br>You may combine multiple terms in sequence to specify multi‑level sorting.<br><br>*Sorting direction:*<br>uppercase = ascending<br>lowercase = descending<br><br>Default is unsorted | `<author[sort(M)]>`<hr>`<author[sort(Fl)]>`<hr>`<author[sort(L FM ID)]>` | Stephen Fry, Arthur Conan Doyle<hr>Stephen King, Stephen Fry<hr>John P. Smith \_B000TTTBBB\_, John P. Smith \_B000TTTCCC\_, John S. Smith \_B000HHHVVV\_ |
|
||||
| unique(FMT) **†** | Reduce list members to a unique set. Entries are compared to each other after applying the given format. Duplicate entries (after format is applied) are removed, keeping the first occurrence. | `<author[unique()]>`<hr>`<author[unique({L:L})`<br>`separator(; )]>` | Arthur Conan Doyle, Stephen Fry<hr>doyle; fry |
|
||||
| sort(T \| F \| M \| L \| S \| ID) | Sorts the names by title,<br> first, middle, or last name,<br>suffix or Audible Contributor ID<br><br>These terms define the primary, secondary, tertiary, … sorting order.<br>You may combine multiple terms in sequence to specify multi‑level sorting.<br><br>*Sorting direction:*<br>uppercase = ascending<br>lowercase = descending<br><br>Default is unsorted | `<author[sort(M)]>`<hr>`<author[sort(Fl)]>`<hr>`<author[sort(L FM ID)]>` | Stephen Fry, Arthur Conan Doyle<hr>Stephen King, Stephen Fry<hr>John P. Smith \_B000TTTBBB\_, John P. Smith \_B000TTTCCC\_, John S. Smith \_B000HHHVVV\_ |
|
||||
| max(#) | Only use the first # of names<br><br>Default is all names | `<author[max(1)]>` | Arthur Conan Doyle |
|
||||
| slice(#..#) | Only use entries of the names list starting from # and ending at # (inclusive)<br><br>See [Text List Formatter Usage](#Text-List-Formatters) above for details on all the variants of `slice()` | `<author[slice(..-2)]>` | Arthur Conan Doyle |
|
||||
| count(FMT) **‡** | Instead of returning some or all members of the list, print out the number of names using the specified [format](#number-formatters). | `<author[count()]>`<hr>`<author[count(00)]>` | 2<hr>02 |
|
||||
|
||||
**†** For further information on format templates, please refer to the [Format templates](#format-templates) section.
|
||||
|
||||
**‡** When `count(FMT)` is used, only the number is output in the specified [number format](#number-formatters). Any other output-related specifications from `separator()` or `format()` are irrelevant here and are ignored.
|
||||
|
||||
### TimeSpan Formatters
|
||||
For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-timespan-format-strings).
|
||||
|
||||
@@ -268,7 +280,7 @@ You can specify which part of a language you are interested in.
|
||||
|
||||
Depending on which property is to be displayed, one or more placeholders can be used in a format template. The placeholders are defined in the form `{A}`:
|
||||
|
||||
`<first author[{F} {U}]>`
|
||||
`<first author[{F} {L}]>`
|
||||
|
||||
The format template must sometimes be enclosed in square brackets and sometimes in round brackets. In addition to placeholders, the format template may also contain arbitrary text. To prevent this text from being mistaken for a bracket at the end of the template or a placeholder, escapes can be used within the text:
|
||||
* `\x` - Escapes the next character.
|
||||
@@ -276,7 +288,7 @@ The format template must sometimes be enclosed in square brackets and sometimes
|
||||
* `"text"` - encloses text that may contain special characters. To include a double quote in the text, escape it by doubling it: `"She said ""Hello"""` will output `She said "Hello"`.
|
||||
* `'text'`- encloses text that may contain special characters. To include a single quote in the text, escape it by doubling it: `'It''s a test'` will output `It's a test`.
|
||||
|
||||
`<series[separator(,) format('{Series:' {N}\})]>`
|
||||
`<series[separator(,) format('{Series:' {N} ({#}\)\})]>`
|
||||
|
||||
Not all elements of a property are always present or have content. In this case, format templates would contain gaps after substitution. Groups of spaces are automatically merged. Other characters, however, remain unchanged. By doubling the curly brackets, you can specify text fragments before and after the placeholder, which are only used if the placeholder is replaced with content.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user