Increase tag template options for contributor and series types

- Add template tag support for multiple series
- Add series ID and contributor ID to template tags
- <first author> and <first narrator> are now name types with name formatter support
- Properly import contributor IDs into database
- Updated docs
This commit is contained in:
Michael Bucari-Tovo 2025-03-24 15:56:32 -06:00
parent 0a9e489f48
commit 7d806e0f3e
31 changed files with 425 additions and 255 deletions

View File

@ -17,6 +17,9 @@ These templates apply to both GUI and CLI.
- [Conditional Tags](#conditional-tags)
- [Tag Formatters](#tag-formatters)
- [Text Formatters](#text-formatters)
- [Series Formatters](#series-formatters)
- [Series List Formatters](#series-list-formatters)
- [Name Formatters](#name-formatters)
- [Name List Formatters](#name-list-formatters)
- [Number Formatters](#number-formatters)
- [Date Formatters](#date-formatters)
@ -32,32 +35,33 @@ These tags will be replaced in the template with the audiobook's values.
|Tag|Description|Type|
|-|-|-|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|\<title\>|Full title with subtitle|Text|
|\<title short\>|Title. Stop at first colon|Text|
|\<audible title\>|Audible's title (does not include subtitle)|Text|
|\<audible subtitle\>|Audible's subtitle|Text|
|\<author\>|Author(s)|Name List|
|\<first author\>|First author|Text|
|\<narrator\>|Narrator(s)|Name List|
|\<first narrator\>|First narrator|Text|
|\<series\>|Name of series|Text|
|\<series#\>|Number order in series|Number|
|\<bitrate\>|File's original bitrate (Kbps)|Number|
|\<samplerate\>|File's original audio sample rate|Number|
|\<channels\>|Number of audio channels|Number|
|\<account\>|Audible account of this book|Text|
|\<account nickname\>|Audible account nickname of this book|Text|
|\<locale\>|Region/country|Text|
|\<year\>|Year published|Number|
|\<language\>|Book's language|Text|
|\<title\>|Full title with subtitle|[Text](#text-formatters)|
|\<title short\>|Title. Stop at first colon|[Text](#text-formatters)|
|\<audible title\>|Audible's title (does not include subtitle)|[Text](#text-formatters)|
|\<audible subtitle\>|Audible's subtitle|[Text](#text-formatters)|
|\<author\>|Author(s)|[Name List](#name-list-formatters)|
|\<first author\>|First author|[Name](#name-formatters)|
|\<narrator\>|Narrator(s)|[Name List](#name-list-formatters)|
|\<first narrator\>|First narrator|[Name](#name-formatters)|
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|\<first series\>|First series|[Series](#series-formatters)|
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|\<channels\>|Number of audio channels|[Number](#number-formatters)|
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|\<locale\>|Region/country|[Text](#text-formatters)|
|\<year\>|Year published|[Number](#number-formatters)|
|\<language\>|Book's language|[Text](#text-formatters)|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|\<file date\>|File creation date/time.|DateTime|
|\<pub date\>|Audiobook publication date|DateTime|
|\<date added\>|Date the book added to your Audible account|DateTime|
|\<ch count\> **‡**|Number of chapters|Number|
|\<ch title\> **‡**|Chapter title|Text|
|\<ch#\> **‡**|Chapter number|Number|
|\<ch# 0\> **‡**|Chapter number with leading zeros|Number|
|\<file date\>|File creation date/time.|[DateTime](#date-formatters)|
|\<pub date\>|Audiobook publication date|[DateTime](#date-formatters)|
|\<date added\>|Date the book added to your Audible account|[DateTime](#date-formatters)|
|\<ch count\> **‡**|Number of chapters|[Number](#number-formatters)|
|\<ch title\> **‡**|Chapter title|[Text](#text-formatters)|
|\<ch#\> **‡**|Chapter number|[Number](#number-formatters)|
|\<ch# 0\> **‡**|Chapter number with leading zeros|[Number](#number-formatters)|
**†** Does not support custom formatting
@ -95,11 +99,28 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet a sherlock holmes novel|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
## Series Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1, B08376S3R2|
## 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|
|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>`<author[format({L}, {ID}) separator(; )]>`|Sherlock Holmes, 1; Some Other Series, 1<hr>herlock Holmes, B08376S3R2; Some Other Series, B000000000|
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
## Name Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\{T \| F \| M \| L \| S \| ID\}|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br>\{ID\} = Audible Contributor ID<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<first narrator[{L}, {F}]>`<hr>`<first author[{L}, {F} _{ID}_]>`|Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
## Name List Formatters
|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|
|format(\{T \| F \| M \| L \| S\})|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<author[format({L}, {F})`<br>`separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|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}, {F})`<br>`separator(; )]>`<hr>`<author[format({L}, {F}`<br>`_{ID}_) separator(; )]>`|Doyle, Arthur; Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|

View File

@ -418,7 +418,6 @@ namespace AppScaffolding
public List<string> Filters { get; set; } = new();
}
public static void migrate_to_v12_0_1(Configuration config)
{
#nullable enable

View File

@ -43,5 +43,7 @@ namespace DataLayer
}
public override string ToString() => Name;
public void SetAudibleContributorId(string audibleContributorId)
=> AudibleContributorId = audibleContributorId;
}
}

View File

@ -61,19 +61,19 @@ namespace DtoImporterService
private int upsertPeople(List<Person> people)
{
var hash = people
// new people only
.Where(p => !Cache.ContainsKey(p.Name))
// remove duplicates by Name. first in wins
.ToDictionarySafe(p => p.Name);
foreach (var kvp in hash)
var qtyNew = 0;
foreach (var person in people)
{
var person = kvp.Value;
addContributor(person.Name, person.Asin);
if (!Cache.TryGetValue(person.Name, out var contributor))
{
contributor = createContributor(person.Name, person.Asin);
qtyNew++;
}
updateContributor(person, contributor);
}
return hash.Count;
return qtyNew;
}
// only use after loading contributors => local
@ -86,16 +86,22 @@ namespace DtoImporterService
.ToHashSet();
foreach (var pub in hash)
addContributor(pub);
createContributor(pub);
return hash.Count;
}
private Contributor addContributor(string name, string id = null)
private void updateContributor(Person person, Contributor contributor)
{
if (person.Asin != contributor.AudibleContributorId)
contributor.SetAudibleContributorId(person.Asin);
}
private Contributor createContributor(string name, string id = null)
{
try
{
var newContrib = new Contributor(name);
var newContrib = new Contributor(name, id);
var entityEntry = DbContext.Contributors.Add(newContrib);
var entity = entityEntry.Entity;

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using DataLayer;
using LibationFileManager;
using LibationFileManager.Templates;
namespace FileLiberator
{

View File

@ -158,7 +158,7 @@ namespace FileLiberator
if (success && config.SaveMetadataToFile)
{
var metadataFile = Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ChapterInfo));

View File

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using System;
using System.IO;
using ApplicationServices;
using LibationFileManager.Templates;
namespace FileLiberator
{
@ -26,8 +27,8 @@ namespace FileLiberator
public string Publisher => LibraryBook.Book.Publisher;
public string Language => LibraryBook.Book.Language;
public string AudibleProductId => LibraryBookDto.AudibleProductId;
public string SeriesName => LibraryBookDto.SeriesName;
public float? SeriesNumber => LibraryBookDto.SeriesNumber;
public string SeriesName => LibraryBookDto.FirstSeries?.Name;
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
public NAudio.Lame.LameConfig LameConfig { get; init; }
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;

View File

@ -5,8 +5,9 @@ using System.Threading.Tasks;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
using LibationFileManager;
using LibationFileManager.Templates;
#nullable enable
namespace FileLiberator
{
public static class UtilityExtensions
@ -47,12 +48,10 @@ namespace FileLiberator
YearPublished = libraryBook.Book.DatePublished?.Year,
DatePublished = libraryBook.Book.DatePublished,
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
Authors = libraryBook.Book.Authors.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
Narrators = libraryBook.Book.Narrators.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
Series = getSeries(libraryBook.Book.SeriesLink),
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
@ -62,5 +61,21 @@ namespace FileLiberator
Language = libraryBook.Book.Language
};
}
private static List<SeriesDto>? getSeries(IEnumerable<SeriesBook> seriesBooks)
{
if (!seriesBooks.Any())
return null;
//I don't remember why or if there was a good reason not to have series numbers for
//podcast parents, but preserving the behavior for backwards compatibility.
return seriesBooks
.Select(sb
=> new SeriesDto(
sb.Series.Name,
sb.Book.IsEpisodeParent() ? null : sb.Index,
sb.Series.AudibleSeriesId)
).ToList();
}
}
}

View File

@ -2,6 +2,7 @@ using Avalonia.Controls;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using LibationFileManager.Templates;
using System.Threading.Tasks;
namespace LibationAvalonia.Controls.Settings

View File

@ -2,6 +2,7 @@ using Avalonia.Controls;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using LibationFileManager.Templates;
using System.Threading.Tasks;
namespace LibationAvalonia.Controls.Settings

View File

@ -5,6 +5,7 @@ using Avalonia.Media;
using Avalonia.Styling;
using Dinah.Core;
using LibationFileManager;
using LibationFileManager.Templates;
using ReactiveUI;
using System;
using System.IO;

View File

@ -12,6 +12,7 @@ using LibationAvalonia.Controls;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using LibationFileManager.Templates;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
@ -350,7 +351,7 @@ namespace LibationAvalonia.Views
#region Edit Templates (Single book only)
async Task editTemplate<T>(LibraryBook libraryBook, string existingTemplate, Action<string> setNewTemplate)
where T : Templates, LibationFileManager.ITemplate, new()
where T : Templates, LibationFileManager.Templates.ITemplate, new()
{
var template = ctx.CreateTemplateEditor<T>(libraryBook, existingTemplate);
var form = new EditTemplateDialog(template);

View File

@ -306,41 +306,41 @@ namespace LibationFileManager
[Description("How to format the folders in which files will be saved")]
public string FolderTemplate
{
get => getTemplate<Templates.FolderTemplate>();
set => setTemplate<Templates.FolderTemplate>(value);
get => getTemplate<Templates.Templates.FolderTemplate>();
set => setTemplate<Templates.Templates.FolderTemplate>(value);
}
[Description("How to format the saved pdf and audio files")]
public string FileTemplate
{
get => getTemplate<Templates.FileTemplate>();
set => setTemplate<Templates.FileTemplate>(value);
get => getTemplate<Templates.Templates.FileTemplate>();
set => setTemplate<Templates.Templates.FileTemplate>(value);
}
[Description("How to format the saved audio files when split by chapters")]
public string ChapterFileTemplate
{
get => getTemplate<Templates.ChapterFileTemplate>();
set => setTemplate<Templates.ChapterFileTemplate>(value);
get => getTemplate<Templates.Templates.ChapterFileTemplate>();
set => setTemplate<Templates.Templates.ChapterFileTemplate>(value);
}
[Description("How to format the file's Title stored in metadata")]
public string ChapterTitleTemplate
{
get => getTemplate<Templates.ChapterTitleTemplate>();
set => setTemplate<Templates.ChapterTitleTemplate>(value);
get => getTemplate<Templates.Templates.ChapterTitleTemplate>();
set => setTemplate<Templates.Templates.ChapterTitleTemplate>(value);
}
private string getTemplate<T>([CallerMemberName] string propertyName = "")
where T : Templates, ITemplate, new()
where T : Templates.Templates, Templates.ITemplate, new()
{
return Templates.GetTemplate<T>(GetString(defaultValue: T.DefaultTemplate, propertyName)).TemplateText;
return Templates.Templates.GetTemplate<T>(GetString(defaultValue: T.DefaultTemplate, propertyName)).TemplateText;
}
private void setTemplate<T>(string newValue, [CallerMemberName] string propertyName = "")
where T : Templates, ITemplate, new()
where T : Templates.Templates, Templates.ITemplate, new()
{
SetString(Templates.GetTemplate<T>(newValue).TemplateText, propertyName);
SetString(Templates.Templates.GetTemplate<T>(newValue).TemplateText, propertyName);
}
#endregion
}

View File

@ -1,45 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace LibationFileManager
{
public class BookDto
{
public string? AudibleProductId { get; set; }
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? TitleWithSubtitle { get; set; }
public string? Locale { get; set; }
public int? YearPublished { get; set; }
public IEnumerable<string>? Authors { get; set; }
public string? AuthorNames => Authors is null ? null : string.Join(", ", Authors);
public string? FirstAuthor => Authors?.FirstOrDefault();
public IEnumerable<string>? Narrators { get; set; }
public string? NarratorNames => Narrators is null? null: string.Join(", ", Narrators);
public string? FirstNarrator => Narrators?.FirstOrDefault();
public string? SeriesName { get; set; }
public float? SeriesNumber { get; set; }
public bool IsSeries => !string.IsNullOrEmpty(SeriesName);
public bool IsPodcastParent { get; set; }
public bool IsPodcast { get; set; }
public int BitRate { get; set; }
public int SampleRate { get; set; }
public int Channels { get; set; }
public DateTime FileDate { get; set; } = DateTime.Now;
public DateTime? DatePublished { get; set; }
public string? Language { get; set; }
}
public class LibraryBookDto : BookDto
{
public DateTime? DateAdded { get; set; }
public string? Account { get; set; }
public string? AccountNickname { get; set; }
}
}

View File

@ -1,98 +0,0 @@
using FileManager.NamingTemplate;
using NameParser;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
#nullable enable
namespace LibationFileManager
{
internal partial class NameListFormat
{
public static string Formatter(ITemplateTag _, IEnumerable<string>? names, string formatString)
{
if (names is null) return "";
var humanNames = names.Select(n => new HumanName(RemoveSuffix(n), Prefer.FirstOverPrefix));
var sortedNames = Sort(humanNames, formatString);
var nameFormatString = Format(formatString, defaultValue: "{T} {F} {M} {L} {S}");
var separatorString = Separator(formatString, defaultValue: ", ");
var maxNames = Max(formatString, defaultValue: humanNames.Count());
var formattedNames = string.Join(separatorString, sortedNames.Take(maxNames).Select(n => FormatName(n, nameFormatString)));
while (formattedNames.Contains(" "))
formattedNames = formattedNames.Replace(" ", " ");
return formattedNames;
}
private static string RemoveSuffix(string namesString)
{
namesString = namesString.Replace('', '\'').Replace(" - Ret.", ", Ret.");
int dashIndex = namesString.IndexOf(" - ");
return (dashIndex > 0 ? namesString[..dashIndex] : namesString).Trim();
}
private static IEnumerable<HumanName> Sort(IEnumerable<HumanName> humanNames, string formatString)
{
var sortMatch = SortRegex().Match(formatString);
return
sortMatch.Success
? sortMatch.Groups[1].Value == "F" ? humanNames.OrderBy(n => n.First)
: sortMatch.Groups[1].Value == "M" ? humanNames.OrderBy(n => n.Middle)
: sortMatch.Groups[1].Value == "L" ? humanNames.OrderBy(n => n.Last)
: humanNames
: humanNames;
}
private static string Format(string formatString, string defaultValue)
{
var formatMatch = FormatRegex().Match(formatString);
return formatMatch.Success ? formatMatch.Groups[1].Value : defaultValue;
}
private static string Separator(string formatString, string defaultValue)
{
var separatorMatch = SeparatorRegex().Match(formatString);
return separatorMatch.Success ? separatorMatch.Groups[1].Value : defaultValue;
}
private static int Max(string formatString, int defaultValue)
{
var maxMatch = MaxRegex().Match(formatString);
return maxMatch.Success && int.TryParse(maxMatch.Groups[1].Value, out var max) ? int.Max(1, max) : defaultValue;
}
private static string FormatName(HumanName humanName, string nameFormatString)
{
//Single-word names parse as first names. Use it as last name.
var lastName = string.IsNullOrWhiteSpace(humanName.Last) ? humanName.First : humanName.Last;
nameFormatString
= nameFormatString
.Replace("{T}", "{0}")
.Replace("{F}", "{1}")
.Replace("{M}", "{2}")
.Replace("{L}", "{3}")
.Replace("{S}", "{4}");
return string.Format(nameFormatString, humanName.Title, humanName.First, humanName.Middle, lastName, humanName.Suffix).Trim();
}
/// <summary> Sort must have exactly one of the characters F, M, or L </summary>
[GeneratedRegex(@"[Ss]ort\(\s*?([FML])\s*?\)")]
private static partial Regex SortRegex();
/// <summary> Format must have at least one of the string {T}, {F}, {M}, {L}, or {S} </summary>
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{[TFMLS]})+.*?)\)")]
private static partial Regex FormatRegex();
/// <summary> Separator can be anything </summary>
[GeneratedRegex(@"[Ss]eparator\((.*?)\)")]
private static partial Regex SeparatorRegex();
/// <summary> Max must have a 1 or 2-digit number </summary>
[GeneratedRegex(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)")]
private static partial Regex MaxRegex();
}
}

View File

@ -0,0 +1,44 @@
using NameParser;
using System;
#nullable enable
namespace LibationFileManager.Templates;
public class ContributorDto : IFormattable
{
public HumanName HumanName { get; }
public string? AudibleContributorId { get; }
public ContributorDto(string name, string? audibleContributorId)
{
HumanName = new HumanName(RemoveSuffix(name), Prefer.FirstOverPrefix);
AudibleContributorId = audibleContributorId;
}
public override string ToString()
=> ToString("{T} {F} {M} {L} {S}", null);
public string ToString(string? format, IFormatProvider? _)
{
if (string.IsNullOrWhiteSpace(format))
return ToString();
//Single-word names parse as first names. Use it as last name.
var lastName = string.IsNullOrWhiteSpace(HumanName.Last) ? HumanName.First : HumanName.Last;
return format
.Replace("{T}", HumanName.Title)
.Replace("{F}", HumanName.First)
.Replace("{M}", HumanName.Middle)
.Replace("{L}", lastName)
.Replace("{S}", HumanName.Suffix)
.Replace("{ID}", AudibleContributorId)
.Trim();
}
private static string RemoveSuffix(string namesString)
{
namesString = namesString.Replace('', '\'').Replace(" - Ret.", ", Ret.");
int dashIndex = namesString.IndexOf(" - ");
return (dashIndex > 0 ? namesString[..dashIndex] : namesString).Trim();
}
}

View File

@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
#nullable enable
namespace LibationFileManager.Templates;
internal partial interface IListFormat<TList> where TList : IListFormat<TList>
{
static string Join<T>(string formatString, IEnumerable<T> items)
where T : IFormattable
{
var itemFormatter = Formatter(formatString);
var separatorString = Separator(formatString) ?? ", ";
var maxValues = Max(formatString) ?? items.Count();
var formattedValues = string.Join(separatorString, items.Take(maxValues).Select(n => n.ToString(itemFormatter, null)));
while (formattedValues.Contains(" "))
formattedValues = formattedValues.Replace(" ", " ");
return formattedValues;
static string? Formatter(string formatString)
{
var formatMatch = TList.FormatRegex().Match(formatString);
return formatMatch.Success ? formatMatch.Groups[1].Value : null;
}
static int? Max(string formatString)
{
var maxMatch = MaxRegex().Match(formatString);
return maxMatch.Success && int.TryParse(maxMatch.Groups[1].Value, out var max) ? int.Max(1, max) : null;
}
static string? Separator(string formatString)
{
var separatorMatch = SeparatorRegex().Match(formatString);
return separatorMatch.Success ? separatorMatch.Groups[1].Value : ", ";
}
}
static abstract Regex FormatRegex();
/// <summary> Separator can be anything </summary>
[GeneratedRegex(@"[Ss]eparator\((.*?)\)")]
private static partial Regex SeparatorRegex();
/// <summary> Max must have a 1 or 2-digit number </summary>
[GeneratedRegex(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)")]
private static partial Regex MaxRegex();
}

View File

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace LibationFileManager.Templates;
public class BookDto
{
public string? AudibleProductId { get; set; }
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? TitleWithSubtitle { get; set; }
public string? Locale { get; set; }
public int? YearPublished { get; set; }
public IEnumerable<ContributorDto>? Authors { get; set; }
public ContributorDto? FirstAuthor => Authors?.FirstOrDefault();
public IEnumerable<ContributorDto>? Narrators { get; set; }
public ContributorDto? FirstNarrator => Narrators?.FirstOrDefault();
public IEnumerable<SeriesDto>? Series { get; set; }
public SeriesDto? FirstSeries => Series?.FirstOrDefault();
public bool IsSeries => Series is not null;
public bool IsPodcastParent { get; set; }
public bool IsPodcast { get; set; }
public int BitRate { get; set; }
public int SampleRate { get; set; }
public int Channels { get; set; }
public DateTime FileDate { get; set; } = DateTime.Now;
public DateTime? DatePublished { get; set; }
public string? Language { get; set; }
}
public class LibraryBookDto : BookDto
{
public DateTime? DateAdded { get; set; }
public string? Account { get; set; }
public string? AccountNickname { get; set; }
}

View File

@ -0,0 +1,33 @@
using FileManager.NamingTemplate;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
#nullable enable
namespace LibationFileManager.Templates;
internal partial class NameListFormat : IListFormat<NameListFormat>
{
public static string Formatter(ITemplateTag _, IEnumerable<ContributorDto>? names, string formatString)
=> names is null ? string.Empty
: IListFormat<NameListFormat>.Join(formatString, Sort(names, formatString));
private static IEnumerable<ContributorDto> Sort(IEnumerable<ContributorDto> names, string formatString)
{
var sortMatch = SortRegex().Match(formatString);
return
sortMatch.Success
? sortMatch.Groups[1].Value == "F" ? names.OrderBy(n => n.HumanName.First)
: sortMatch.Groups[1].Value == "M" ? names.OrderBy(n => n.HumanName.Middle)
: sortMatch.Groups[1].Value == "L" ? names.OrderBy(n => n.HumanName.Last)
: names
: names;
}
/// <summary> Sort must have exactly one of the characters F, M, or L </summary>
[GeneratedRegex(@"[Ss]ort\(\s*?([FML])\s*?\)")]
private static partial Regex SortRegex();
/// <summary> Format must have at least one of the string {T}, {F}, {M}, {L}, {S}, or {ID} </summary>
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{[TFMLS]}|{ID})+.*?)\)")]
public static partial Regex FormatRegex();
}

View File

@ -0,0 +1,27 @@
using System;
#nullable enable
namespace LibationFileManager.Templates;
public record SeriesDto : IFormattable
{
public string Name { get; }
public float? Number { get; }
public string AudibleSeriesId { get; }
public SeriesDto(string name, float? number, string audibleSeriesId)
{
Name = name;
Number = number;
AudibleSeriesId = audibleSeriesId;
}
public override string ToString() => Name.Trim();
public string ToString(string? format, IFormatProvider? _)
=> string.IsNullOrWhiteSpace(format) ? ToString()
: format
.Replace("{N}", Name)
.Replace("{#}", Number?.ToString())
.Replace("{ID}", AudibleSeriesId)
.Trim();
}

View File

@ -0,0 +1,17 @@
using FileManager.NamingTemplate;
using System.Collections.Generic;
using System.Text.RegularExpressions;
#nullable enable
namespace LibationFileManager.Templates;
internal partial class SeriesListFormat : IListFormat<SeriesListFormat>
{
public static string Formatter(ITemplateTag _, IEnumerable<SeriesDto>? series, string formatString)
=> series is null ? string.Empty
: IListFormat<SeriesListFormat>.Join(formatString, series);
/// <summary> Format must have at least one of the string {N}, {#}, {ID} </summary>
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{[N#]}|{ID})+.*?)\)")]
public static partial Regex FormatRegex();
}

View File

@ -1,11 +1,10 @@
using AaxDecrypter;
using FileManager;
using System.Collections.Generic;
using System;
using System.IO;
#nullable enable
namespace LibationFileManager
namespace LibationFileManager.Templates
{
public interface ITemplateEditor
{
@ -61,16 +60,15 @@ namespace LibationFileManager
AccountNickname = "my account",
DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
AudibleProductId = "123456789",
AudibleProductId = "B06WLMWF2S",
Title = "A Study in Scarlet",
TitleWithSubtitle = "A Study in Scarlet: A Sherlock Holmes Novel",
Subtitle = "A Sherlock Holmes Novel",
Locale = "us",
YearPublished = 2017,
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = "Sherlock Holmes",
SeriesNumber = 1,
Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")],
Narrators = [new("Stephen Fry", null)],
Series = [new("Sherlock Holmes", 1, "B08376S3R2"), new("Some Other Series", 1, "B000000000")],
BitRate = 128,
SampleRate = 44100,
Channels = 2,

View File

@ -1,7 +1,7 @@
using FileManager.NamingTemplate;
#nullable enable
namespace LibationFileManager
namespace LibationFileManager.Templates
{
public sealed class TemplateTags : ITemplateTag
{
@ -33,15 +33,15 @@ namespace LibationFileManager
public static TemplateTags FirstAuthor { get; } = new TemplateTags("first author", "First author");
public static TemplateTags Narrator { get; } = new TemplateTags("narrator", "Narrator(s)");
public static TemplateTags FirstNarrator { get; } = new TemplateTags("first narrator", "First narrator");
public static TemplateTags Series { get; } = new TemplateTags("series", "Name of series");
// can't also have a leading zeros version. Too many weird edge cases. Eg: "1-4"
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series");
public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)");
public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series");
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for <first series[{#}]>");
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "File's orig. bitrate");
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate");
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels");
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book");
public static TemplateTags Locale { get; } = new ("locale", "Region/country");
public static TemplateTags Locale { get; } = new("locale", "Region/country");
public static TemplateTags YearPublished { get; } = new("year", "Year published");
public static TemplateTags Language { get; } = new("language", "Book's language");
public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG");

View File

@ -10,7 +10,7 @@ using FileManager.NamingTemplate;
using NameParser;
#nullable enable
namespace LibationFileManager
namespace LibationFileManager.Templates
{
public interface ITemplate
{
@ -58,19 +58,19 @@ namespace LibationFileManager
{
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.FolderTemplate))]
(_,e) => _folder = GetTemplate<FolderTemplate>(e.NewValue as string);
(_, e) => _folder = GetTemplate<FolderTemplate>(e.NewValue as string);
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.FileTemplate))]
(_, e) => _file = GetTemplate<FileTemplate>(e.NewValue as string);
(_, e) => _file = GetTemplate<FileTemplate>(e.NewValue as string);
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))]
(_, e) => _chapterFile = GetTemplate<ChapterFileTemplate>(e.NewValue as string);
(_, e) => _chapterFile = GetTemplate<ChapterFileTemplate>(e.NewValue as string);
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))]
(_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>(e.NewValue as string);
(_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>(e.NewValue as string);
HumanName.Suffixes.Add("ret");
HumanName.Titles.Add("professor");
@ -121,7 +121,7 @@ namespace LibationFileManager
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
replacements ??= Configuration.Instance.ReplacementCharacters;
return GetFilename(baseDir, fileExtension,replacements, returnFirstExisting, libraryBookDto);
return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto);
}
public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false)
@ -170,7 +170,7 @@ namespace LibationFileManager
var fullPath = Path.Combine(pathParts.Select(fileParts => string.Concat(fileParts)).Prepend(baseDir).ToArray());
return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting);
return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting);
}
/// <summary>
@ -186,7 +186,7 @@ namespace LibationFileManager
foreach (var part in templateParts)
{
int slashIndex, lastIndex = 0;
while((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1)
while ((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1)
{
dir.Add(part[lastIndex..slashIndex]);
RemoveSpaces(dir);
@ -229,7 +229,7 @@ namespace LibationFileManager
{
original = parts[i];
parts[i] = original.Replace(" ", " ");
}while(original.Length != parts[i].Length);
} while (original.Length != parts[i].Length);
}
//Remove instances of double spaces at part boundaries
@ -262,11 +262,12 @@ namespace LibationFileManager
{ TemplateTags.AudibleTitle, lb => lb.Title },
{ TemplateTags.AudibleSubtitle, lb => lb.Subtitle },
{ TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter },
{ TemplateTags.FirstAuthor, lb => lb.FirstAuthor },
{ TemplateTags.FirstAuthor, lb => lb.FirstAuthor, FormattableFormatter },
{ TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter },
{ TemplateTags.FirstNarrator, lb => lb.FirstNarrator },
{ TemplateTags.Series, lb => lb.SeriesName },
{ TemplateTags.SeriesNumber, lb => lb.IsPodcastParent ? null : lb.SeriesNumber },
{ TemplateTags.FirstNarrator, lb => lb.FirstNarrator, FormattableFormatter },
{ TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter },
{ TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter },
{ TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Number },
{ TemplateTags.Language, lb => lb.Language },
//Don't allow formatting of LanguageShort
{ TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort },
@ -280,7 +281,7 @@ namespace LibationFileManager
{ TemplateTags.DatePublished, lb => lb.DatePublished },
{ TemplateTags.DateAdded, lb => lb.DateAdded },
{ TemplateTags.FileDate, lb => lb.FileDate },
};
};
private static readonly List<TagCollection> chapterPropertyTags = new()
{
@ -290,7 +291,8 @@ namespace LibationFileManager
{ TemplateTags.TitleShort, lb => getTitleShort(lb.Title) },
{ TemplateTags.AudibleTitle, lb => lb.Title },
{ TemplateTags.AudibleSubtitle, lb => lb.Subtitle },
{ TemplateTags.Series, lb => lb.SeriesName },
{ TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter },
{ TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter },
},
new PropertyTagCollection<MultiConvertFileProperties>(caseSensative: true, StringFormatter, IntegerFormatter, DateTimeFormatter)
{
@ -332,6 +334,9 @@ namespace LibationFileManager
return language[..3].ToUpper();
}
private static string FormattableFormatter(ITemplateTag templateTag, IFormattable? value, string formatString)
=> value?.ToString(formatString, null) ?? "";
private static string StringFormatter(ITemplateTag templateTag, string value, string formatString)
{
if (value is null) return "";
@ -368,7 +373,7 @@ namespace LibationFileManager
public class FolderTemplate : Templates, ITemplate
{
public static string Name { get; }= "Folder Template";
public static string Name { get; } = "Folder Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title short> [<id>]";
public static IEnumerable<TagCollection> TagCollections

View File

@ -2,6 +2,7 @@
using DataLayer;
using FileLiberator;
using LibationFileManager;
using LibationFileManager.Templates;
using System;
using System.Linq;
using System.Threading.Tasks;
@ -25,7 +26,7 @@ public class GridContextMenu
public string FolderTemplateText => "Folder Template";
public string FileTemplateText => "File Template";
public string MultipartTemplateText => "Multipart File Template";
public string ViewBookmarksText => "View _Bookmarks/Clips";
public string ViewBookmarksText => $"View {Accelerator}Bookmarks/Clips";
public string ViewSeriesText => GridEntries[0].Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series";
public bool LiberateEpisodesEnabled => GridEntries.OfType<ISeriesEntry>().Any(sEntry => sEntry.Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload));

View File

@ -4,6 +4,7 @@ using System.IO;
using System.Windows.Forms;
using Dinah.Core;
using LibationFileManager;
using LibationFileManager.Templates;
namespace LibationWinForms.Dialogs
{

View File

@ -2,6 +2,7 @@
using LibationFileManager;
using System.Linq;
using LibationUiBase;
using LibationFileManager.Templates;
namespace LibationWinForms.Dialogs
{

View File

@ -2,6 +2,7 @@
using System.Linq;
using Dinah.Core;
using LibationFileManager;
using LibationFileManager.Templates;
namespace LibationWinForms.Dialogs
{

View File

@ -2,6 +2,7 @@
using System.Linq;
using System.Windows.Forms;
using LibationFileManager;
using LibationFileManager.Templates;
namespace LibationWinForms.Dialogs
{

View File

@ -3,6 +3,7 @@ using AudibleUtilities;
using DataLayer;
using FileLiberator;
using LibationFileManager;
using LibationFileManager.Templates;
using LibationUiBase.GridView;
using LibationWinForms.Dialogs;
using LibationWinForms.SeriesView;
@ -258,7 +259,7 @@ namespace LibationWinForms.GridView
#region Edit Templates (Single book only)
void editTemplate<T>(LibraryBook libraryBook, string existingTemplate, Action<string> setNewTemplate)
where T : Templates, LibationFileManager.ITemplate, new()
where T : Templates, ITemplate, new()
{
var template = ctx.CreateTemplateEditor<T>(libraryBook, existingTemplate);
var form = new EditTemplateDialog(template);
@ -280,8 +281,8 @@ namespace LibationWinForms.GridView
var editTemplatesMenuItem = new ToolStripMenuItem { Text = ctx.EditTemplatesText };
editTemplatesMenuItem.DropDownItems.AddRange(new[] { folderTemplateMenuItem, fileTemplateMenuItem, multiFileTemplateMenuItem });
ctxMenu.Items.Add(new ToolStripSeparator());
ctxMenu.Items.Add(editTemplatesMenuItem);
ctxMenu.Items.Add(new ToolStripSeparator());
}
#endregion

View File

@ -6,7 +6,7 @@ using Dinah.Core;
using FileManager;
using FileManager.NamingTemplate;
using FluentAssertions;
using LibationFileManager;
using LibationFileManager.Templates;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using static TemplatesTests.Shared;
@ -23,7 +23,10 @@ namespace TemplatesTests
public static class Shared
{
public static LibraryBookDto GetLibraryBook(string seriesName = "Sherlock Holmes")
public static LibraryBookDto GetLibraryBook()
=> GetLibraryBook([new SeriesDto("Sherlock Holmes", 1, "B08376S3R2")]);
public static LibraryBookDto GetLibraryBook(IEnumerable<SeriesDto> series)
=> new()
{
Account = "myaccount@example.co",
@ -35,10 +38,9 @@ namespace TemplatesTests
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
Locale = "us",
YearPublished = 2017,
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = seriesName ?? "",
SeriesNumber = 1,
Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")],
Narrators = [new("Stephen Fry", "B000APAGVS"), new("Some Narrator", "B000000000")],
Series = series,
BitRate = 128,
SampleRate = 44100,
Channels = 2,
@ -253,7 +255,6 @@ namespace TemplatesTests
}
}
[TestMethod]
[DataRow("<filedate[yy-MM-dd]> <date added[yy-MM-dd]> <pubdate[yy-MM]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\23-01-28.m4b")]
public void DateFormat_null(string template, string dirFullPath, string extension, string expected)
@ -308,7 +309,7 @@ namespace TemplatesTests
public void NameFormat_unusual(string author, string expected)
{
var bookDto = GetLibraryBook();
bookDto.Authors = new List<string> { author };
bookDto.Authors = [new(author, null)];
Templates.TryGetTemplate<Templates.FileTemplate>("<author[format(Title={T}, First={F}, Middle={M} Last={L}, Suffix={S})]>", out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(bookDto, "", "", Replacements)
@ -329,6 +330,11 @@ namespace TemplatesTests
[DataRow("<author[max(2)]>", "Jill Conner Browne, Charles E. Gannon")]
[DataRow("<author[max(3)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf")]
[DataRow("<author[format({L}, {F})]>", "Browne, Jill, Gannon, Charles, Fetherolf, Christopher, Montgomery, Lucy, Bon Jovi, Jon, Van Doren, Paul")]
[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")]
[DataRow("<author[format({ID})]>", "B1, B2, B3, B4, B5, B6")]
[DataRow("<author[format({Id})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author[format({iD})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author[format({id})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author[format({f}, {l})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author[format(First={F}, Last={L})]>", "First=Jill, Last=Browne, First=Charles, Last=Gannon, First=Christopher, Last=Fetherolf, First=Lucy, Last=Montgomery, First=Jon, Last=Bon Jovi, First=Paul, Last=Van Doren")]
[DataRow("<author[format({L}, {F}) separator( - ) max(3)]>", "Browne, Jill - Gannon, Charles - Fetherolf, Christopher")]
@ -337,18 +343,21 @@ namespace TemplatesTests
//Jon Bon Jovi and Paul Van Doren don't have middle names, so they are sorted to the top.
//Since only the middle names of the first 2 names are to be displayed, the name string is empty.
[DataRow("<author[sort(M) max(2) separator(; ) format({M})]>", ";")]
[DataRow("<first author>", "Jill Conner Browne")]
[DataRow("<first author[]>", "Jill Conner Browne")]
[DataRow("<first author[{L}, {F}]>", "Browne, Jill")]
public void NameFormat_formatters(string template, string expected)
{
var bookDto = GetLibraryBook();
bookDto.Authors = new List<string>
{
"Jill Conner Browne",
"Charles E. Gannon",
"Christopher John Fetherolf",
"Lucy Maud Montgomery",
"Jon Bon Jovi",
"Paul Van Doren"
};
bookDto.Authors =
[
new("Jill Conner Browne", "B1"),
new("Charles E. Gannon", "B2"),
new("Christopher John Fetherolf", "B3"),
new("Lucy Maud Montgomery", "B4"),
new("Jon Bon Jovi", "B5"),
new("Paul Van Doren", "B6")
];
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
@ -358,6 +367,35 @@ namespace TemplatesTests
}
[TestMethod]
[DataRow("<series>", "Series A, Series B, Series C")]
[DataRow("<series[]>", "Series A, Series B, Series C")]
[DataRow("<series[max(1)]>", "Series A")]
[DataRow("<series[max(2)]>", "Series A, Series B")]
[DataRow("<series[max(3)]>", "Series A, Series B, Series C")]
[DataRow("<series[format({N}, {#}, {ID}) separator(; )]>", "Series A, 1, B1; Series B, 6, B2; Series C, 2, B3")]
[DataRow("<series[format({N}, {#}, {ID}) separator(; ) max(3)]>", "Series A, 1, B1; Series B, 6, B2; Series C, 2, B3")]
[DataRow("<series[format({N}, {#}, {ID}) separator(; ) max(2)]>", "Series A, 1, B1; Series B, 6, B2")]
[DataRow("<first series>", "Series A")]
[DataRow("<first series[]>", "Series A")]
[DataRow("<first series[{N}, {#}, {ID}]>", "Series A, 1, B1")]
public void SeriesFormat_formatters(string template, string expected)
{
var bookDto = GetLibraryBook();
bookDto.Series =
[
new("Series A", 1, "B1"),
new("Series B", 6, "B2"),
new("Series C", 2, "B3")
];
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(bookDto, "", "", Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
[TestMethod]
[DataRow(@"C:\a\b", @"C:\a\b\foobar.ext", PlatformID.Win32NT)]
[DataRow(@"/a/b", @"/a/b/foobar.ext", PlatformID.Unix)]