QS updates:

- fixed documentation
- regexp-checks running with timeout and culture-invariant matching
- changed check-building in ConditionalTagCollection to use NonNull parameters. So no warnings occure.
- add tests for <!is ...> and escaped chars
This commit is contained in:
Jo-Be-Co
2026-03-22 19:18:55 +01:00
parent 7ecd571364
commit 02b9b4fa1c
3 changed files with 103 additions and 57 deletions

View File

@@ -83,7 +83,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider<TClass> valueProvider, ConditionEvaluator conditionEvaluator)
: base(templateTag, Expression.Constant(false))
{
// <property> needs to match on at least one character which is not a space
// <property> needs to match on at least one character, which is not a space
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
@@ -103,8 +103,8 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
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
// <property> needs to match on at least one character, which is not a space.
// though we will capture the group named `check` enclosed in [] at the end of the tag, the property itself might 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
@@ -155,28 +155,33 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
private static ConditionEvaluator GetPredicate(string? checkString)
{
if (checkString == null)
return DefaultPredicate;
return (v, _) => v switch
{
null => false,
IEnumerable<object> e => e.Any(),
_ => !string.IsNullOrWhiteSpace(v.ToString())
};
var match = CheckRegex().Match(checkString);
var valStr = match.Groups["val"].Value;
var ival = -1;
var isNumop = match.Groups["numop"].Success && int.TryParse(valStr, out ival);
var iVal = -1;
var isNumericalOperator = match.Groups["num_op"].Success && int.TryParse(valStr, out iVal);
var checkItem = match.Groups["op"].ValueSpan switch
Func<object, CultureInfo?, bool> checkItem = match.Groups["op"].ValueSpan switch
{
"=" or "" => (v, culture) => VComparedToStr(v, culture, valStr) == 0,
"!=" or "!" => (v, culture) => VComparedToStr(v, culture, valStr) != 0,
"~" => GetRegExpCheck(valStr),
"#=" => (v, _) => VAsInt(v) == ival,
"#!=" => (v, _) => VAsInt(v) != ival,
"#>=" or ">=" => (v, _) => VAsInt(v) >= ival,
"#>" or ">" => (v, _) => VAsInt(v) > ival,
"#<=" or "<=" => (v, _) => VAsInt(v) <= ival,
"#<" or "<" => (v, _) => VAsInt(v) < ival,
_ => DefaultPredicate,
"#=" => (v, _) => VAsInt(v) == iVal,
"#!=" => (v, _) => VAsInt(v) != iVal,
"#>=" or ">=" => (v, _) => VAsInt(v) >= iVal,
"#>" or ">" => (v, _) => VAsInt(v) > iVal,
"#<=" or "<=" => (v, _) => VAsInt(v) <= iVal,
"#<" or "<" => (v, _) => VAsInt(v) < iVal,
_ => (v, _) => !string.IsNullOrWhiteSpace(v.ToString())
};
return isNumop
return isNumericalOperator
? (v, culture) => v switch
{
null => false,
@@ -201,34 +206,54 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
}
/// <summary>
/// build a regular expression check which take the <see cref="CultureInfo"/> into account.
/// 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.
/// </summary>
/// <param name="valStr"></param>
/// <param name="valStr">The regex pattern to match</param>
/// <returns>check function to validate an object</returns>
private static ConditionEvaluator GetRegExpCheck(string valStr)
private static Func<object, CultureInfo?, bool> GetRegExpCheck(string valStr)
{
return (v, culture) =>
try
{
var old = CultureInfo.CurrentCulture;
try
// Compile regex with timeout to prevent catastrophic backtracking
var regex = new Regex(valStr,
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100));
return (v, _) =>
{
CultureInfo.CurrentCulture = culture ?? CultureInfo.CurrentCulture;
return Regex.IsMatch(v?.ToString().Trim() ?? "", valStr, RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
finally
{
CultureInfo.CurrentCulture = old;
}
};
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(v.ToString()?.Trim() ?? "");
}
catch (RegexMatchTimeoutException)
{
// Return false if regex evaluation times out
return false;
}
};
}
catch
{
// If regex compilation fails, return a predicate that always returns false
return (_, _) => false;
}
}
// 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())
};
// 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)
{
@@ -256,11 +281,11 @@ 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> # capture operator in <op> and <numop>
(?<numop>\#=|\#!=|\#?>=|\#?>|\#?<=|\#?<) # - numerical operators start with a # and might be omitted if unique
(?<op> # capture operator in <op> and <num_op>
(?<num_op>\#=|\#!=|\#?>=|\#?>|\#?<=|\#?<) # - numerical operators start with a # and might be omitted if unique
| ~|!=?|=? # - string comparison operators including ~ for regexp. No operator is like =
) \s* # ignore space between operator and value
(?<val>(?(numop) # capture value in <val>
(?<val>(?(num_op) # capture value in <val>
\d+ # - numerical operators have to be followed by a number
| .*? ) # - string for comparison. May be empty. Non-greedy capture resulting in no whitespace at the end
)\s*$ # trimming up to the end

View File

@@ -54,7 +54,7 @@ namespace TemplatesTests
Language = "English",
Subtitle = "An Audible Original Drama",
TitleWithSubtitle = "A Study in Scarlet: An Audible Original Drama",
Codec = "AAC-LC",
Codec = @"AAC[LC]\MP3", // special chars added
FileVersion = null, // explicitly null
LibationVersion = "", // explicitly empty string
LengthInMinutes = 100,
@@ -167,7 +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("<codec[t]> <samplerate[6]>Hz", "Aac-Lc 044100Hz")]
[DataRow("<codec[t]> <samplerate[6]>Hz", "Aac[Lc]Mp3 044100Hz")]
[DataRow("<codec[3T]> <titleshort[ 5 U ]>", "AAC A STU")]
[DataRow("<bitrate [ 4 ] >Kbps <samplerate [ 6 ] >Hz", "0128Kbps 044100Hz")]
public void FormatTags(string template, string expected)
@@ -433,27 +433,36 @@ namespace TemplatesTests
[TestMethod]
[DataRow("<has libation version->empty-string<-has>", "")]
[DataRow("<!has libation version->empty-string<-has>", "empty-string")]
[DataRow("<is libation version[=foobar]->empty-string<-has>", "")]
[DataRow("<!is libation version[=foobar]->empty-string<-has>", "empty-string")]
[DataRow("<is libation version[=]->empty-string<-has>", "empty-string")]
[DataRow("<is libation version[#=0]->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>", "null-string")]
[DataRow("<is file version[=foobar]->null-string<-has>", "")]
[DataRow("<is file version[=]->null-string<-has>", "")]
[DataRow("<!is file version[=]->null-string<-has>", "null-string")]
[DataRow("<is file version[#=0]->null-string<-has>", "")]
[DataRow("<has file version[]->null-string<-has>", "")]
[DataRow("<is file version[]->null-string<-has>", "")]
[DataRow("<has year->null-int<-has>", "")]
[DataRow("<is year[=]->null-int<-has>", "")]
[DataRow("<is year[#=0]->null-int<-has>", "")]
[DataRow("<is year[0]->null-int<-has>", "")]
[DataRow("<!is year[0]->null-int<-has>", "null-int")]
[DataRow("<is year[]->null-int<-has>", "")]
[DataRow("<has FAKE->unknown-tag<-has>", "")]
[DataRow("<is FAKE[=]->unknown-tag<-has>", "")]
[DataRow("<!is FAKE[=]->unknown-tag<-has>", "unknown-tag")]
[DataRow("<is FAKE[=foobar]->unknown-tag<-has>", "")]
[DataRow("<is FAKE[#=0]->unknown-tag<-has>", "")]
[DataRow("<is FAKE[]->unknown-tag<-has>", "")]
[DataRow("<has narrator->empty-list<-has>", "")]
[DataRow("<is narrator[=foobar]->empty-list<-has>", "")]
[DataRow("<!is narrator[=foobar]->empty-list<-has>", "empty-list")]
[DataRow("<is narrator[!=foobar]->empty-list<-has>", "")]
[DataRow("<!is narrator[!=foobar]->empty-list<-has>", "empty-list")]
[DataRow("<is narrator[=]->empty-list<-has>", "")]
[DataRow("<is narrator[~.*]->empty-list<-has>", "")]
[DataRow("<is narrator[<1]->empty-list<-has>", "empty-list")]
@@ -484,11 +493,13 @@ namespace TemplatesTests
[TestMethod]
[DataRow("<has id->true<-has>", "true")]
[DataRow("<!has id->false<-has>", "")]
[DataRow("<has title->true<-has>", "true")]
[DataRow("<has title short->true<-has>", "true")]
[DataRow("<has audible title->true<-has>", "true")]
[DataRow("<has audible subtitle->true<-has>", "true")]
[DataRow("<has author->true<-has>", "true")]
[DataRow("<!has author->false<-has>", "")]
[DataRow("<has first author->true<-has>", "true")]
[DataRow("<has series->true<-has>", "true")]
[DataRow("<has first series->true<-has>", "true")]
@@ -497,6 +508,8 @@ namespace TemplatesTests
[DataRow("<has samplerate->true<-has>", "true")]
[DataRow("<has channels->true<-has>", "true")]
[DataRow("<has codec->true<-has>", "true")]
[DataRow(@"<is codec[=aac\[lc\]\\mp3]->true<-has>", "true")]
[DataRow(@"<is codec[=aac\[lc\]\\mp4]->true<-has>", "")]
[DataRow("<has account->true<-has>", "true")]
[DataRow("<has account nickname->true<-has>", "true")]
[DataRow("<has locale->true<-has>", "true")]
@@ -510,9 +523,11 @@ namespace TemplatesTests
[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[=A Study in Scarlet: An Audible Original Drama]->false<-has>", "")]
[DataRow("<is title[U][=A STUDY IN SCARLET: AN AUDIBLE ORIGINAL DRAMA]->true<-has>", "true")]
[DataRow("<is title[#=45]->true<-has>", "true")]
[DataRow("<is title[!=foo]->true<-has>", "true")]
[DataRow("<!is title[!=foo]->false<-has>", "")]
[DataRow("<is title[~A Study.*]->true<-has>", "true")]
[DataRow("<is title[foo]->true<-has>", "")]
[DataRow("<is ch count[>=1]->true<-has>", "true")]
@@ -524,15 +539,21 @@ namespace TemplatesTests
[DataRow("<is author[#=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})][=Doyle]->false<-has>", "")]
[DataRow("<is author[format({L})][!=Doyle]->true<-has>", "true")]
[DataRow("<!is author[format({L})][!=Doyle]->false<-has>", "")]
[DataRow("<is author[format({L})separator(:)][=Doyle:Fry]->true<-has>", "true")]
[DataRow("<is author[>=3]->true<-has>", "")]
[DataRow("<is author[slice(99)][~.*]->true<-has>", "")]
[DataRow(@"<is author[slice(99)][~.\*]->true<-has>", "")]
[DataRow("<is author[slice(99)separator(:)][~.*]->true<-has>", "")]
[DataRow("<is author[slice(-9)separator(:)][~.*]->true<-has>", "")]
[DataRow("<is author[slice(2..1)separator(:)][~.*]->true<-has>", "")]
[DataRow("<is author[slice(-1..1)separator(:)][~.*]->true<-has>", "")]
[DataRow("<is author[slice(-1..-2)separator(:)][~.*]->true<-has>", "")]
[DataRow("<is author[=Sherlock]->true<-has>", "")]
[DataRow("<!is author[=Sherlock]->false<-has>", "false")]
[DataRow("<is author[!=Sherlock]->true<-has>", "true")]
[DataRow("<!is author[!=Sherlock]->false<-has>", "")]
[DataRow("<is tag[=Tag1]->true<-has>", "true")]
[DataRow("<is tag[separator(:)slice(-2..)][=Tag2:Tag3]->true<-has>", "true")]
public void HasValue_test(string template, string expected)

View File

@@ -123,10 +123,10 @@ Text formatting can change length and case of the text. Use <#>, <#><case> or <c
### Text List Formatters
| Formatter | Description | Example Usage | Example Result |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- | -------------------------------------------- |
| separator() | Speficy the text used to join<br>multiple entries.<br><br>Default is ", " | `<tag[separator(_)]>` | Tag1_Tag2_Tag3_Tag4_Tag5 |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |--------------------------------------------- | ---------------------------------------------|
| 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_Formatter](#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;Tag4;Tag2;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 |
@@ -136,15 +136,15 @@ Text formatting can change length and case of the text. Use <#>, <#><case> or <c
### Series Formatters
| Formatter | Description | Example Usage | Example Result |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| \{N \| # \| ID\} | Formats the series using<br>the series part tags.<br>\{N:[Text_Formatter](#text-formatters)\} = Series Name<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series<br>\{ID:[Text_Formatter](#text-formatters)\} = Audible Series ID<br><br>Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the correspoing formatter.<br><br>Default is \{N\} | `<first series>`<hr>`<first series[{N:l}]>`<hr>`<first series[{N}, {#}, {ID}]>`<hr>`<first series[{N:10U}, {ID}, {#:00.0}]>` | Sherlock Holmes<hr>sherlock holmes<hr>Sherlock Holmes, 1-6, B08376S3R2<hr>SHERLOCK H, B08376S3R2, 01.0-06.0 |
| Formatter | Description | Example Usage | Example Result |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| \{N \| # \| ID\} | Formats the series using<br>the series part tags.<br>\{N:[Text_Formatter](#text-formatters)\} = Series Name<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series<br>\{ID:[Text_Formatter](#text-formatters)\} = Audible Series ID<br><br>Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.<br><br>Default is \{N\} | `<first series>`<hr>`<first series[{N:l}]>`<hr>`<first series[{N}, {#}, {ID}]>`<hr>`<first series[{N:10U}, {ID}, {#:00.0}]>` | Sherlock Holmes<hr>sherlock holmes<hr>Sherlock Holmes, 1-6, B08376S3R2<hr>SHERLOCK H, B08376S3R2, 01.0-06.0 |
### Series List Formatters
| Formatter | Description | Example Usage | Example Result |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| separator() | Speficy the text used to join<br>multiple series names.<br><br>Default is ", " | `<series[separator(; )]>` | Sherlock Holmes; Some Other Series |
| 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 |
| 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 multilevel 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 |
@@ -160,7 +160,7 @@ Text formatting can change length and case of the text. Use <#>, <#><case> or <c
| Formatter | Description | Example Usage | Example Result |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| separator() | Speficy the text used to join<br>multiple people's names.<br><br>Default is ", " | `<author[separator(; )]>` | Arthur Conan Doyle; Stephen Fry |
| 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 multilevel 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 |
@@ -168,9 +168,9 @@ Text formatting can change length and case of the text. Use <#>, <#><case> or <c
### Minutes Formatters
| Formatter | Description | Example Usage | Example Result |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------- |
| {M \| H \| D} | Format the minutes value in terms of minutes, hours and days.<br>{D:[Number_Formatter](#number-formatter) = Number of full days<br>{H:[Number_Formatter](#number-formatter) = Number of full (remaining) hours<br>{M:[Number_Formatter](#number-formatter) = Number of (remaining) minutes<br><br>Default is {M} | `<minutes[{M:4}minutes]>`<hr>`<minutes[{D:2}d {M:2}m]>`<hr>`<minutes[{D}-{H}-{M}]>` | 03000minutes<hr>02d 120m<hr>2-2-0 |
| Formatter | Description | Example Usage | Example Result |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | --------------------------------- |
| {M \| H \| D} | Format the minutes value in terms of minutes, hours and days.<br>{D:[Number_Formatter](#number-formatters) = Number of full days<br>{H:[Number_Formatter](#number-formatters) = Number of full (remaining) hours<br>{M:[Number_Formatter](#number-formatters) = Number of (remaining) minutes<br><br>Default is {M} | `<minutes[{M:4}minutes]>`<hr>`<minutes[{D:2}d {M:2}m]>`<hr>`<minutes[{D}-{H}-{M}]>` | 03000minutes<hr>02d 120m<hr>2-2-0 |
### Number Formatters
@@ -183,7 +183,7 @@ For more custom formatters and examples, [see this guide from Microsoft](https:/
### Date Formatters
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
For more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
#### Standard DateTime Formatters