diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index faffe03f3..bb639bb52 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -25,6 +25,8 @@ private TemplateTags(string tagName, string description, string? defaultValue = public static TemplateTags Id { get; } = new("id", "Audible ID"); public static TemplateTags Title { get; } = new("title", "Full title with subtitle"); public static TemplateTags TitleShort { get; } = new("title short", "Title. Stop at first colon"); + public static TemplateTags TitleSort { get; } = new("title sort", "Full title with subtitle, leading article (A/An/The) removed"); + public static TemplateTags TitleShortSort { get; } = new("title short sort", "Title without subtitle, leading article (A/An/The) removed"); public static TemplateTags AudibleTitle { get; } = new("audible title", "Audible's title (does not include subtitle)"); public static TemplateTags AudibleSubtitle { get; } = new("audible subtitle", "Audible's subtitle"); public static TemplateTags Author { get; } = new("author", "Author(s)"); @@ -33,6 +35,7 @@ private TemplateTags(string tagName, string description, string? defaultValue = public static TemplateTags FirstNarrator { get; } = new("first narrator", "First narrator"); public static TemplateTags Series { get; } = new("series", "All series to which the book belongs (if any)"); public static TemplateTags FirstSeries { get; } = new("first series", "First series"); + public static TemplateTags FirstSeriesSort { get; } = new("first series sort", "First series name, leading article (A/An/The) removed"); public static TemplateTags SeriesNumber { get; } = new("series#", "Number order in series (alias for "); public static TemplateTags Minutes { get; } = new("minutes", "Length in minutes"); public static TemplateTags Bitrate { get; } = new("bitrate", "Bitrate (kbps) of the last downloaded audiobook"); diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index d6c57085a..a9778dbb9 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -269,6 +269,8 @@ private static void RemoveSpaces(List parts) { TemplateTags.Id, lb => lb.AudibleProductId, v => v }, { TemplateTags.Title, lb => lb.TitleWithSubtitle }, { TemplateTags.TitleShort, lb => GetTitleShort(lb.Title) }, + { TemplateTags.TitleSort, lb => StripLeadingArticle(lb.TitleWithSubtitle) }, + { TemplateTags.TitleShortSort, lb => StripLeadingArticle(GetTitleShort(lb.Title)) }, { TemplateTags.AudibleTitle, lb => lb.Title }, { TemplateTags.AudibleSubtitle, lb => lb.Subtitle }, { TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter, NameListFormat.Finalizer }, @@ -277,6 +279,7 @@ private static void RemoveSpaces(List parts) { TemplateTags.FirstNarrator, lb => lb.FirstNarrator, CommonFormatters.FormattableFormatter }, { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter, SeriesListFormat.Finalizer }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, CommonFormatters.FormattableFormatter }, + { TemplateTags.FirstSeriesSort, lb => StripLeadingArticle(lb.FirstSeries?.ToString()) }, { TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, CommonFormatters.FormattableFormatter }, { TemplateTags.Language, lb => lb.Language, CommonFormatters.FormattableFormatter }, //Don't allow formatting of LanguageShort @@ -312,10 +315,13 @@ private static void RemoveSpaces(List parts) { { TemplateTags.Title, lb => lb.TitleWithSubtitle }, { TemplateTags.TitleShort, lb => GetTitleShort(lb.Title) }, + { TemplateTags.TitleSort, lb => StripLeadingArticle(lb.TitleWithSubtitle) }, + { TemplateTags.TitleShortSort, lb => StripLeadingArticle(GetTitleShort(lb.Title)) }, { TemplateTags.AudibleTitle, lb => lb.Title }, { TemplateTags.AudibleSubtitle, lb => lb.Subtitle }, { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter, SeriesListFormat.Finalizer }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, CommonFormatters.FormattableFormatter }, + { TemplateTags.FirstSeriesSort, lb => StripLeadingArticle(lb.FirstSeries?.ToString()) }, }, new PropertyTagCollection(caseSensitive: true, CommonFormatters.StringFormatter, CommonFormatters.IntegerFormatter, CommonFormatters.DateTimeFormatter) { @@ -397,6 +403,17 @@ private static bool HasValue(object? value, object? _, CultureInfo? culture) ? title[..i] : title; + private static readonly string[] _sortArticles = ["The ", "A ", "An "]; + + private static string? StripLeadingArticle(string? value) + { + if (value is null) return null; + foreach (var article in _sortArticles) + if (value.StartsWith(article, StringComparison.OrdinalIgnoreCase)) + return value[article.Length..]; + return value; + } + #endregion public class FolderTemplate : Templates, ITemplate diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 680c36d19..a6fa95e08 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -1517,4 +1517,60 @@ public void Tests(string template, string dir, string ext, int pos, int total, s .Should().Be(expected); } } + + [TestClass] + public class SortTags + { + static LibraryBookDto Book(string titleWithSubtitle, string title, string? seriesName = null) + { + var dto = GetLibraryBook(seriesName is null ? null : [new SeriesDto(seriesName, "1", "sid")]); + dto.Title = title; + dto.TitleWithSubtitle = titleWithSubtitle; + return dto; + } + + private static string Evaluate(string template, LibraryBookDto dto) + { + Templates.TryGetTemplate(template, out var t).Should().BeTrue(); + return t.GetName(dto, new MultiConvertFileProperties { OutputFileName = string.Empty }); + } + + [TestMethod] + [DataRow("The Hobbit", "The Hobbit", "Hobbit")] + [DataRow("A Tale of Two Cities", "A Tale of Two Cities", "Tale of Two Cities")] + [DataRow("An American in Paris", "An American in Paris", "American in Paris")] + [DataRow("Foundation", "Foundation", "Foundation")] // no article — unchanged + [DataRow("Theatre of War", "Theatre of War", "Theatre of War")] // "The" must be whole word + public void TitleSort_strips_leading_article(string titleWithSubtitle, string title, string expected) + => Evaluate("", Book(titleWithSubtitle, title)).Should().Be(expected); + + [TestMethod] + [DataRow("The Hobbit: There and Back Again", "The Hobbit", "Hobbit")] + [DataRow("A Tale: Subtitle", "A Tale", "Tale")] + [DataRow("Foundation: Prelude", "Foundation", "Foundation")] // no article + public void TitleShortSort_strips_leading_article(string titleWithSubtitle, string title, string expected) + => Evaluate("<title short sort>", Book(titleWithSubtitle, title)).Should().Be(expected); + + [TestMethod] + [DataRow("The Lord of the Rings", "Lord of the Rings")] + [DataRow("A Series of Unfortunate Events", "Series of Unfortunate Events")] + [DataRow("An Inspector Calls", "Inspector Calls")] + [DataRow("Sherlock Holmes", "Sherlock Holmes")] // no article — unchanged + public void FirstSeriesSort_strips_leading_article(string seriesName, string expected) + => Evaluate("<first series sort>", Book("any", "any", seriesName)).Should().Be(expected); + + [TestMethod] + public void TitleSort_case_insensitive() + => Evaluate("<title sort>", Book("the hobbit", "the hobbit")).Should().Be("hobbit"); + + [TestMethod] + public void TitleSort_available_in_chapter_template() + { + Templates.TryGetTemplate<Templates.ChapterFileTemplate>("<title sort>", out var t).Should().BeTrue(); + var dto = GetLibraryBook(); + dto.TitleWithSubtitle = "The Silmarillion"; + t.GetName(dto, new MultiConvertFileProperties { OutputFileName = string.Empty }) + .Should().Be("Silmarillion"); + } + } }