Merge pull request #1803 from Jo-Be-Co/1762_filter

1762 add filter() to list properties
This commit is contained in:
rmcrackan
2026-05-14 11:29:26 -04:00
committed by GitHub
9 changed files with 167 additions and 79 deletions

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
@@ -66,7 +67,7 @@ public static partial class CommonFormatters
{
if (string.IsNullOrWhiteSpace(templateString)) return "";
// is this function is called from toString implementation of the IFormattable interface, we only get a IFormatProvider
// if this function is called from toString implementation of the IFormattable interface, we only get a IFormatProvider
var culture = GetCultureInfo(provider);
var oldUiCulture = Thread.CurrentThread.CurrentUICulture;
var result = CollapseSpacesAndTrimRegex().Replace(TagFormatRegex().ReplaceWithGaps(templateString, GetValueForMatchingTag, Unescape), string.Empty);
@@ -211,6 +212,33 @@ public static partial class CommonFormatters
return StringFormatter(templateTag, language, "3u", culture);
}
public static bool TryGetLiteral(string? value, [NotNullWhen(true)] out object? literal)
{
// check if value is quoted
if (StringValueRegex().TryMatch(value, out var stringValue))
{
// inside the quotes, doubled quotes are used to represent literal quotes. So replace them back to single quotes if there are any.
// this match helps to determine which quote type is being used so that the correct one can be replaced.
var doubleQuote = stringValue.Groups["double"];
literal = doubleQuote.Success
? stringValue.Groups["value"].Value.Replace(doubleQuote.Value, stringValue.Groups["quote"].Value)
: stringValue.Groups["value"].Value;
return true;
}
if (int.TryParse(value, out var intValue))
{
literal = intValue;
return true;
}
literal = null;
return false;
}
[GeneratedRegex("""^\s*(?<quote>['"])(?<value>(?:(?<double>\k<quote>{2})|.)*)\k<quote>\s*$""")]
private static partial Regex StringValueRegex();
public static string Unescape(string valueSpan)
{
return Unescape(valueSpan, ['\'', '"']);

View File

@@ -264,7 +264,7 @@ public static partial class CompareCondition
[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
^(?>(?<op>(?<list_op> # anchor at start of line. Capture operator in <op>, <list_op> and <num_op>
≡ | == | :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

View File

@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
@@ -28,7 +27,7 @@ public delegate object? ValueProvider<in T>(ITemplateTag templateTag, T value, s
public delegate bool ConditionEvaluator(object? value1, object? value2, CultureInfo? culture);
public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive)
public class ConditionalTagCollection<TClass>(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive)
{
/// <summary>
/// Register a conditional tag.
@@ -74,7 +73,7 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider1, valueProvider2));
}
private partial class ConditionalTag : TagBase, IClosingPropertyTag
private class ConditionalTag : TagBase, IClosingPropertyTag
{
public override Regex NameMatcher { get; }
public Regex NameCloseMatcher { get; }
@@ -162,8 +161,8 @@ public partial class ConditionalTagCollection<TClass>(bool caseSensitive = true)
| . )+? # - match any character to form the property name. Capture non greedy so it won't match the operator part.
(?<!\s)) # - don't let <property> end with a whitepace. Otherwise "<tagname = tag2->" would be matchable.
\s+ # Separate the following operand with whitespace
(?<check_or_op> # capture operator in <op> and <num_op> with every char escapable
[\#!≡=≠~<>≤≥&∉∌∈∌⋂⊆⊇⊂⊃-]+ # allow a wide range of operators, all non alphanumeric
(?<check_or_op> # capture operator in <check_or_op>
[\#!≡=≠~<>≤≥&∉∌∈∌⋂⊆⊇⊂⊃-]+ # allow a wide range of operators, all non alphanumeric so that no operator is confused as property
| :[a-z_]+: # allow :named: operators for readability, e.g. :contains:
) \s+ # ignore space between operator and second property
(?<second_property>.+? # - capture the <second_property> non greedy so it won't end on whitespace

View File

@@ -11,13 +11,31 @@ internal partial interface IListFormat<TList> where TList : IListFormat<TList>
{
static IEnumerable<T> FilteredList<T>(string formatString, IEnumerable<T> items, CultureInfo? culture) where T : IFormattable
{
return Max(formatString, Slice(formatString, Unique(formatString, items, culture)));
return Max(formatString, Slice(formatString, Unique(formatString, Filter(formatString, items, culture), culture)));
static StringComparer GetStringComparer(CultureInfo? culture)
{
return StringComparer.Create(culture ?? CultureInfo.CurrentCulture, ignoreCase: true);
}
static IEnumerable<T> Filter(string formatString, IEnumerable<T> items, CultureInfo? culture)
{
if (!FilterRegex().TryMatch(formatString, out var filterMatch))
return items;
// read the format to apply on each item
var format = filterMatch.ResolveValue("format");
// use the operator to get a predicate function that compares the formatted item to the value specified in the filter
var predicate = CompareCondition.GetPredicate(filterMatch.Value, filterMatch.ResolveValue("op"));
// the value to compare the formatted item to. Might be a number or a quoted string.
CommonFormatters.TryGetLiteral(filterMatch.ResolveValue("value"), out var value);
// return only the items that match the predicate
return items.Where(FilterPredicate);
bool FilterPredicate(T n) => predicate(n.ToString(format, culture), value, culture);
}
static IEnumerable<T> Unique(string formatString, IEnumerable<T> items, CultureInfo? culture)
{
return UniqueRegex().TryMatch(formatString, out var uniqueMatch)
@@ -120,11 +138,36 @@ internal partial interface IListFormat<TList> where TList : IListFormat<TList>
[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>
/// <summary> Count will substitute all list members with a single number equal to their 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();
/// <summary> The filter will reduce the list, keeping only the items that match the specified criteria. </summary>
[GeneratedRegex("""
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
[Ff]ilter # name of the command 'filter' or 'Filter'
\( # details are enclosed in brackets
(?<format>(?: # the first part captured as <format> specifies how to format items before comparison
\\. # - '\' escapes always the next character.
| '[^']*' # - 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 other character. This will not catch the operator at first. Because ...
) *? ) # With *? the pattern above tries not to consume the operator.
\s* # Separate the following operator with whitespace
(?<op> # capture operator in <op>
[\#!≡=≠~<>≤≥&∉∌∈∌⋂⊆⊇⊂⊃-]+ # allow a wide range of operators, all non alphanumeric so that no operator is confused as value
| :[a-z_]+: # allow :named: operators for readability, e.g. :contains:
) \s* # ignore space between operator and second property
(?<value> # the second operand is captured as <value> and is a quoted string encapsulated in either single or double quotes
'(?:[^']|'')*' # - 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
| \d+ # - allow a number
) #
\s* \) # end the filter details with optional whitespace and a closing bracket
""")]
private static partial Regex FilterRegex();
}

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AaxDecrypter;
using Dinah.Core;
using FileManager;
@@ -20,7 +19,7 @@ public interface ITemplate
static abstract IEnumerable<TagCollection> TagCollections { get; }
}
public abstract partial class Templates
public abstract class Templates
{
public const string ErrorFullPathIsInvalid = @"No colons or full paths allowed. Eg: should not start with C:\";
public const string WarningNoChapterNumberTag = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
@@ -353,20 +352,10 @@ public abstract partial class Templates
private static object? TryGetValue(ITemplateTag _, CombinedDto dtos, string property, CultureInfo? culture)
{
// check for string literal first
if (StringValueRegex().TryMatch(property, out var stringValue))
// check for literal (string or int)
if (CommonFormatters.TryGetLiteral(property, out var stringValue))
{
// inside the quotes, doubled quotes are used to represent literal quotes. So replace them back to single quotes if there are any.
// this match helps to determine which quote type is being used so that the correct one can be replaced.
var doubleQuote = stringValue.Groups["double"];
return doubleQuote.Success
? stringValue.Groups["value"].Value.Replace(doubleQuote.Value, stringValue.Groups["quote"].Value)
: stringValue.Groups["value"].Value;
}
// then check for int literal
if (int.TryParse(property, out var intVal))
{
return intVal;
return stringValue;
}
// then check for property tags and retrieve their value
@@ -388,17 +377,14 @@ public abstract partial class Templates
return null;
}
[GeneratedRegex(@"^\s*(?<quote>['""])(?<value>(?:(?<double>\k<quote>{2})|.)*)\k<quote>\s*$")]
private static partial Regex StringValueRegex();
private static bool HasValue(object? value, object? _, CultureInfo? culture)
{
bool CheckItem(object o, CultureInfo? _) => !string.IsNullOrWhiteSpace(o.ToString());
bool CheckItem(object o) => !string.IsNullOrWhiteSpace(o.ToString());
return value switch
{
null => false,
IEnumerable<object> e => e.Any(o => CheckItem(o, culture)),
_ => CheckItem(value, culture)
IEnumerable<object> e => e.Any(CheckItem),
_ => CheckItem(value)
};
}

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using AssertionHelper;
using FileManager.NamingTemplate;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -322,10 +321,33 @@ public class CommonFormattersTests
Assert.AreEqual(expected, unescaped);
}
[TestMethod]
[DataRow(null, false, null, "null")]
[DataRow("", false, null, "emptystring")]
[DataRow("42", true, 42, "number")]
[DataRow("\"only\" at start", false, null, "partly quoted")]
[DataRow("\"mismatched'", false, null, "wrong pair of quotes")]
[DataRow("\"simple string\"", true, "simple string", "simple quoted with double quotes")]
[DataRow("'simple string'", true, "simple string", "simple quoted with single quotes")]
[DataRow("\"string with \"\"escaped\"\" quotes\"", true, "string with \"escaped\" quotes", "quoted with embedded doubled quotes")]
[DataRow("'string with ''escaped'' quotes'", true, "string with 'escaped' quotes", "quoted with embedded doubled single quotes")]
[DataRow("\"string with 'single' quotes\"", true, "string with 'single' quotes", "quoted with embedded other quote type")]
[DataRow("\"string with ''doubled single'' and \\\"escaped double\\\" quotes\"", true, "string with ''doubled single'' and \\\"escaped double\\\" quotes", "quoted with embedded doubling")]
[DataRow(" \"string with whitespace\" ", true, "string with whitespace", "quoted with whitespace")]
[DataRow("\"\"", true, "", "empty quoted string")]
public void TryGetLiteral_Various(string? value, bool expectedSuccess, object? expectedValue, string testDescription)
{
// WHEN
var result = CommonFormatters.TryGetLiteral(value, out var unQuotedValue);
// THEN
Assert.AreEqual(expectedSuccess, result, $"Failed for: {testDescription}");
Assert.AreEqual(expectedValue, unQuotedValue, $"Failed for: {testDescription}");
}
private class TestClass
{
public string? Author { get; set; }
public string? Title { get; set; }
public string? Author { get; init; }
public string? Title { get; init; }
}
}
}

View File

@@ -126,4 +126,4 @@ public class ConditionalTagCollectionTests
// Assert: Should parse successfully without exceptions
Assert.IsNotNull(namingTemplate);
}
}
}

View File

@@ -400,6 +400,12 @@ namespace TemplatesTests
[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[filter({L:1} = 'B') count()]>", "2")]
[DataRow("<author[filter({F:1}~{L}~'J~B') format({L})]>", "Browne, Bon Jovi")] // match correct position of operator
[DataRow(@"<author[filter({F:1}\'{L:1} = 'J''B') format({L})]>", "Browne, Bon Jovi")] // allow quoted quotes
[DataRow("<author[filter(<'99') count()]>", "")] // strings with numerical operators are substituted by their length
[DataRow("<author[filter(<26) count()]>", "6")]
[DataRow("<author[filter({L:1} != 'B') format({L}) slice(2..3)]>", "Fetherolf, Montgomery")]
[DataRow("<author[count()]>", "7")]
[DataRow("<author[max(42) count()]>", "7")]
[DataRow("<author[max(2) count()]>", "2")]
@@ -873,6 +879,7 @@ namespace TemplatesTests
[DataRow("<tag[count(00)]>", "03")]
[DataRow("<tag[unique({S:3}) sort(s)]>", "Tag3")]
[DataRow("<tag[unique({S:3}) count()]>", "1")]
[DataRow("<tag[filter(~ '[2-5]')]>", "Tag2, Tag3")]
[DataRow("<tag [max(1)]>", "Tag1")]
[DataRow("<tag [slice(2..)]>", "Tag2, Tag3")]
[DataRow("<tag[sort(s)]>", "Tag3, Tag2, Tag1")]

View File

@@ -76,7 +76,7 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w
| \<is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if the PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional |
| \<is PROPERTY[FORMAT][[CHECK](#checks)]-\>...\<-is\> | Only include if the formatted PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional |
| \<is PROPERTY[...separator(...)...][[CHECK](#checks)]-\>...\<-is\> | Only include if the joined form of all formatted values of a list PROPERTY satisfies the CHECK | Conditional |
| \<cmp 1st-PROPERTY [[CHECK](#checks)] 2nd-PROPERTY-\>...\<-cmp\> | Only include if two given PROPERTIES satisfy the CHECK | Conditional |
| \<cmp 1st-PROPERTY [CHECK](#checks) 2nd-PROPERTY-\>...\<-cmp\> | Only include if two given PROPERTIES satisfy the CHECK | Conditional |
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
@@ -84,15 +84,15 @@ For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name.
| Inverted Tag | Description | Type |
|--------------------------------------------------------|--------------------------------------------------------------------------------------------------| ----------- |
| \<!if series-\>...\<-if series\> | Only include if _not_ part of a book series or podcast | Conditional |
| \<!if podcast-\>...\<-if podcast\> | Only include if _not_ part of a podcast | Conditional |
| \<!if bookseries-\>...\<-if bookseries\> | Only include if _not_ part of a book series | Conditional |
| \<!if podcastparent-\>...\<-if podcastparent\> **†** | Only include if item is _not_ a podcast series parent | Conditional |
| \<!has PROPERTY-\>...\<-has\> | Only include if the PROPERTY _does not_ have a value (i.e. is null or empty) | Conditional |
| \<!is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if neither the whole PROPERTY nor the values of a list PROPERTY satisfies the CHECK | Conditional |
| \<!cmp 1st-PROPERTY [CHECK] 2nd-PROPERTY-\>...\<-cmp\> | Only include if two given PROPERTIES _do not_ satisfy the CHECK | Conditional |
| Inverted Tag | Description | Type |
|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------------| ----------- |
| \<!if series-\>...\<-if series\> | Only include if _not_ part of a book series or podcast | Conditional |
| \<!if podcast-\>...\<-if podcast\> | Only include if _not_ part of a podcast | Conditional |
| \<!if bookseries-\>...\<-if bookseries\> | Only include if _not_ part of a book series | Conditional |
| \<!if podcastparent-\>...\<-if podcastparent\> **†** | Only include if item is _not_ a podcast series parent | Conditional |
| \<!has PROPERTY-\>...\<-has\> | Only include if the PROPERTY _does not_ have a value (i.e. is null or empty) | Conditional |
| \<!is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if neither the whole PROPERTY nor the values of a list PROPERTY satisfies the CHECK | Conditional |
| \<!cmp 1st-PROPERTY [CHECK](#checks) 2nd-PROPERTY-\>...\<-cmp\> | Only include if two given PROPERTIES _do not_ satisfy the CHECK | Conditional |
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
@@ -131,19 +131,20 @@ 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 |
| 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 |
| 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 |
| filter(FMT [CHECK](#checks) VALUE) **†** | Filter list entries based on a condition. Each item is first formatted using the specified text format (or the default format if FMT is omitted), then compared against VALUE using the specified [CHECK](#checks). Only matching entries are included in the output.<br><br>**Syntax:** `filter(FORMAT CHECK VALUE)` or `filter(CHECK VALUE)`<br>- `FORMAT`: Optional text format to apply to each entry (e.g., `{S}`, `{S:L}`, `{S:3}`); defaults to `{S}`<br>- `CHECK`: Comparison operator (e.g., `=`, `!=`, `~`)<br>- `VALUE`: The value to compare against | `<tag[filter({S} ~ '[3-9]')]>`<hr>`<tag[filter({S} != 'Ignore')]>` | Tag3, Tag4, Tag5<hr>Tag1, Tag2, Tag3, Tag4, Tag5 |
**†** For further information on format templates, please refer to the [Format templates](#format-templates) section.
@@ -159,15 +160,16 @@ 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 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 |
| 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 |
| 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 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 |
| 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 |
| filter(FMT [CHECK](#checks) VALUE) **†** | Filter list entries based on a condition. Each series is first formatted using the specified [Series Format](#series-formatters) (or the default format if FMT is omitted), then compared against VALUE using the specified [CHECK](#checks). Only matching entries are included in the output.<br><br>**Syntax:** `filter(FORMAT CHECK VALUE)` or `filter(CHECK VALUE)`<br>- `FORMAT`: Optional series format to apply to each entry (e.g., `{N}`, `{N:L}`, `{#}`); defaults to `{N}`<br>- `CHECK`: Comparison operator (e.g., `=`, `!=`, `~`)<br>- `VALUE`: The value to compare against | `<series[filter({N} = 'Holmes')]>`<hr>`<series[filter(~ "Sherlock")]>` | Sherlock Holmes<hr>Sherlock Holmes |
**†** For further information on format templates, please refer to the [Format templates](#format-templates) section.
@@ -183,15 +185,16 @@ 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\_ |
| 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 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 |
| 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 |
| 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\_ |
| 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 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 |
| 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 |
| filter(FMT [CHECK](#checks) VALUE) **†** | Filter list entries based on a condition. Each person is first formatted using the specified name format (or the default format if FMT is omitted), then compared against VALUE using the specified [CHECK](#checks). Only matching entries are included in the output.<br><br>**Syntax:** `filter(FORMAT CHECK VALUE)` or `filter(CHECK VALUE)`<br>- `FORMAT`: Optional name format to apply to each entry (e.g., `{L}`, `{M}`, `{L}, {F}`); defaults to `{T} {F} {M} {L} {S}`<br>- `CHECK`: Comparison operator (e.g., `=`, `!=`, `~`)<br>- `VALUE`: The value to compare against | `<author[filter({L} = 'Doyle')]>`<hr>`<author[filter({F} ~ '^Stephen')]>`<hr>`<narrator[filter(~ "Fry")]>` | Conan Doyle<hr>Stephen Fry<hr>Stephen Fry |
**†** For further information on format templates, please refer to the [Format templates](#format-templates) section.
@@ -314,9 +317,9 @@ string literal `O'Reilly`, you can use either `'O''Reilly'` or `"O'Reilly"`.
| String Checks | Unicode Operator | Description | Examples |
|---------------|------------------|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------|
| = | | Matches if values are equal (case-insensitive) | \<is tag[=Tag1]-\> |
| != | | Matches if values are not equal (case-insensitive) | \<is first author[!=Arthur]-\><br>\<cmp "foo" != 'bar'-\> |
| ~ | | Matches if the first parameter matches the regular expression specified by the second parameter (case-insensitive) | \<is title[~(\[XYZ\]).*\\1]-\> |
| = | | Matches if values are equal (case-insensitive) | \<is tag[=Tag1]-\><br>\<author[filter({L} = 'Doyle')]> |
| != | | Matches if values are not equal (case-insensitive) | \<is first author[!=Arthur]-\><br>\<cmp "foo" != 'bar'-\><br>\<tag[filter({S} != 'Ignore')]> |
| ~ | | Matches if the first parameter matches the regular expression specified by the second parameter (case-insensitive) | \<is title[~(\[XYZ\]).*\\1]-\><br>\<tag[filter({S} ~ '[3-9]')]><br>\<narrator[filter({F} ~ '^Stephen')]> |
| Number Checks | Unicode Operator | Description | Examples |
|---------------|------------------|----------------------------------------------------------------|-----------------------------------------------------------|