As stated in the "Naming Templates" documentation, support custom formatters on numbers.

Only trim spaces on tags where they are disruptive.
This commit is contained in:
Jo-Be-Co
2026-03-25 02:34:32 +01:00
parent 6d2b0b952d
commit 14d8bb2205
5 changed files with 32 additions and 32 deletions

View File

@@ -72,7 +72,7 @@ public static class UtilityExtensions
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
LengthInMinutes = libraryBook.Book.LengthInMinutes,
Language = libraryBook.Book.Language,
Language = libraryBook.Book.Language?.Trim(),
Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString,
BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate,
SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate,

View File

@@ -34,7 +34,7 @@ public static partial class CommonFormatters
private static string _StringFormatter(string? value, string? formatString, CultureInfo? culture)
{
if (string.IsNullOrEmpty(value)) return string.Empty;
if (string.IsNullOrEmpty(formatString)) return value;
if (string.IsNullOrWhiteSpace(formatString)) return value;
var match = StringFormatRegex().Match(formatString);
if (!match.Success) return value;
@@ -60,7 +60,7 @@ public static partial class CommonFormatters
public static string TemplateStringFormatter<T>(T toFormat, string? templateString, IFormatProvider? provider, Dictionary<string, Func<T, object?>> replacements)
{
if (string.IsNullOrEmpty(templateString)) return "";
if (string.IsNullOrWhiteSpace(templateString)) return "";
// 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;
@@ -89,7 +89,7 @@ public static partial class CommonFormatters
// 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.
// TemplateStringFormatter will use a dictionary to lookup the tagname and the corresponding value getter.
[GeneratedRegex(@"\{(?<tag>[[A-Z]+|#)(?::(?<format>.*?))?\}", RegexOptions.IgnoreCase)]
[GeneratedRegex("""\{(?<tag>[A-Z]+|#)(?::(?<format>(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)]
private static partial Regex TagFormatRegex();
public static string FormattableFormatter(ITemplateTag _, IFormattable? value, string? formatString, CultureInfo? culture)
@@ -113,7 +113,7 @@ public static partial class CommonFormatters
public static string MinutesFormatter(ITemplateTag templateTag, int value, string? formatString, CultureInfo? culture)
{
culture ??= CultureInfo.CurrentCulture;
if (string.IsNullOrEmpty(formatString))
if (string.IsNullOrWhiteSpace(formatString))
return value.ToString(culture);
var timeSpan = TimeSpan.FromMinutes(value);
@@ -148,23 +148,23 @@ public static partial class CommonFormatters
public static string DateTimeFormatter(ITemplateTag _, DateTime value, string? formatString, CultureInfo? culture)
{
culture ??= CultureInfo.InvariantCulture;
if (string.IsNullOrEmpty(formatString))
if (string.IsNullOrWhiteSpace(formatString))
formatString = DefaultDateFormat;
return value.ToString(formatString, culture);
}
public static string LanguageShortFormatter(ITemplateTag templateTag, string? language, string? formatString, CultureInfo? culture)
{
return StringFormatter(templateTag, language?.Trim(), "3u", culture);
return StringFormatter(templateTag, language, "3u", culture);
}
// Regex to find patterns like {D:3}, {h:4}, {m}
[GeneratedRegex(@"\{D(?::(?<format>.*?))?\}", RegexOptions.IgnoreCase)]
[GeneratedRegex("""\{D(?::(?<format>(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)]
private static partial Regex RegexMinutesD();
[GeneratedRegex(@"\{H(?::(?<format>.*?))?\}", RegexOptions.IgnoreCase)]
[GeneratedRegex("""\{H(?::(?<format>(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)]
private static partial Regex RegexMinutesH();
[GeneratedRegex(@"\{M(?::(?<format>.*?))?\}", RegexOptions.IgnoreCase)]
[GeneratedRegex("""\{M(?::(?<format>(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)]
private static partial Regex RegexMinutesM();
}

View File

@@ -114,11 +114,10 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
(?:\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
(?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall start with an operator. 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
|[^\\\]])* ) # - match any character except '\' and ']'. Check may end in whitespace!
\])? # - 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
@@ -250,7 +249,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
// - Lithuanian locale: 'i' after 'ž' has an accent that affects sorting/matching.
//
// For naming templates, culture-invariant is the safer default.
return regex.IsMatch(v.ToString()?.Trim() ?? "");
return regex.IsMatch(v.ToString() ?? "");
}
catch (RegexMatchTimeoutException ex)
{
@@ -307,13 +306,12 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
[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>(?<num_op> # capture operator in <op> and <num_op> with every char escapable
^(?<op>(?<num_op> # anchor at start of linecapture operator in <op> and <num_op> with every char escapable
\\?\#(?:\\?!)?\\?= # - numerical operators: #= #!=
| \\?\#\\?[<>](?:\\?=)? # - numerical operators: #>= #<= #> #<
| \\?[<>](?:\\?=)? # - numerical operators: >= <= > <
) | \\?~|\\?!(?:\\?=)?|(?:\\?=)? # - string comparison operators including ~ for regexp, = and !=. No operator is like =
) \s* # ignore space between operator and value
) \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.

View File

@@ -189,17 +189,17 @@ public class PropertyTagCollection<TClass> : TagCollection
: base(templateTag, propertyGetter)
{
NameMatcher = new Regex($"""
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
^< # tags start with a '<'
{TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#'
(?:\s* # optional whitespace
\[\s* # optional format details enclosed in '[' and ']'. Format shall be trimmed. So match whitespace first
(?<format> # - capture inner part as <format>
(?:\\. # - '\' 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 format is optional
\] # - closing the format part
)?\s*> # Tags end with '>'
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
^< # tags start with a '<'
{TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#'
(?:\s* # optional whitespace
\[ (?<format> # optional format details enclosed in '[' and ']'. Capture inner part as <format>.
(?:\\. # - '\' escapes allways the next character. Especially further '\' and the closing ']'
|'(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character
|"(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character
|[^\\\]])* ) # - match any character except '\' and ']'. Format may end in whitespace!
\] # - closing the format part
)?\s*> # Tags end with '>'
"""
, options);

View File

@@ -167,6 +167,7 @@ namespace TemplatesTests
[DataRow("<bitrate[2]>Kbps <titleshort[u]>", "128Kbps A STUDY IN SCARLET")]
[DataRow("<bitrate[3]>Kbps <titleshort[t]>", "128Kbps A Study In Scarlet")]
[DataRow("<bitrate[4]>Kbps <titleshort[l]>", "0128Kbps a study in scarlet")]
[DataRow(@"<bitrate[00'['0\\#0']']>Kbps <titleshort[T]>", "01[2#8]Kbps A Study In Scarlet")]
[DataRow("<codec[7t]> <samplerate[6]>Hz", "Aac[Lc] 044100Hz")]
[DataRow("<codec[3T]> <titleshort[ 5 U ]>", "AAC A STU")]
[DataRow("<bitrate [ 4 ] >Kbps <samplerate [ 6 ] >Hz", "0128Kbps 044100Hz")]
@@ -208,6 +209,7 @@ namespace TemplatesTests
[DataRow("<minutes[{d}-{m}]>", 2000, "1-560")]
[DataRow("<minutes[{d}-{m}]>", 2880, "2-0")]
[DataRow("<minutes[{d:2}-{m:2}]>", 1500, "01-60")]
[DataRow(@"<minutes[{d:0}-{m:000'{'00\}}]>", 2000, "1-005{60}")]
public void MinutesFormat(string template, int minutes, string expected)
{
var bookDto = GetLibraryBook();
@@ -557,14 +559,14 @@ namespace TemplatesTests
[DataRow("<is tag[=Tag1]->true<-has>", "true")]
[DataRow("<is tag[separator(:)slice(-2..)][=Tag2:Tag3]->true<-has>", "true")]
[DataRow("<is audible subtitle[3][=an]->false<-has>", "")]
[DataRow("<is audible subtitle[3][=an ]->false<-has>", "")]
[DataRow("<is audible subtitle[3][=an ]->true<-has>", "true")]
[DataRow(@"<is audible subtitle[3][=an\ ]->true<-has>", "true")]
[DataRow("<is audible subtitle[3][= an]->false<-has>", "")]
[DataRow("<is audible subtitle[3][= an ]->false<-has>", "")]
[DataRow(@"<is audible subtitle[3][= an\ ]->true<-has>", "true")]
[DataRow(@"<is audible subtitle[3][= an\ ]->false<-has>", "")]
[DataRow(@"<is audible subtitle[3][=\ an\ ]->false<-has>", "")]
[DataRow("<is audible subtitle[3][ =an]->false<-has>", "")]
[DataRow("<is audible subtitle[3][ =an ]->false<-has>", "")]
[DataRow("<is audible subtitle[3][ =an ]->true<-has>", "true")]
[DataRow(@"<is audible subtitle[3][ =an\ ]->true<-has>", "true")]
public void HasValue_test(string template, string expected)
{
@@ -598,7 +600,7 @@ namespace TemplatesTests
[DataRow("<first series>", "Series A")]
[DataRow("<first series[]>", "Series A")]
[DataRow("<first series[{N}, {#}, {ID}]>", "Series A, 1, B1")]
[DataRow("<first series[{N}, {#:00.0}]>", "Series A, 01.0")]
[DataRow("<first series[{N}, {#:0'{}'0.0}]>", "Series A, 0{}1.0")]
public void SeriesFormat_formatters(string template, string expected)
{
var bookDto = GetLibraryBook();