From c8c5a173b4d168285c9757dc330b7512dc96586c Mon Sep 17 00:00:00 2001 From: AK Clark Date: Wed, 13 May 2026 22:33:52 -0500 Subject: [PATCH 1/2] Add , <title short sort>, <first series sort> template tags (#1620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new file/folder naming template tags that strip a leading article (A/An/The, case-insensitive) from the resolved value: <title sort> — full title, article removed <title short sort> — title up to first colon, article removed <first series sort>— first series name, article removed Useful for organizing libraries so "The Hobbit" files into "H/" instead of "T/". Article stripping is additive-only; existing templates are unchanged. Covered by unit tests in TemplatesTests.SortTags. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../Templates/TemplateTags.cs | 3 + .../Templates/Templates.cs | 17 ++++++ .../TemplatesTests.cs | 56 +++++++++++++++++++ 3 files changed, 76 insertions(+) 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 <first series[{#}]>"); 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<string> 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<string> 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<string> 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<MultiConvertFileProperties>(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..72e56713e 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 = Shared.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<Templates.FileTemplate>(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("<title sort>", 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 = Shared.GetLibraryBook(); + dto.TitleWithSubtitle = "The Silmarillion"; + t.GetName(dto, new MultiConvertFileProperties { OutputFileName = string.Empty }) + .Should().Be("Silmarillion"); + } + } } From b4e9b908f4efca947e3e0c28a9a8b9a8d107a46e Mon Sep 17 00:00:00 2001 From: AK Clark <akclark@example.com> Date: Fri, 15 May 2026 00:54:17 -0500 Subject: [PATCH 2/2] Fix SortTags test compile errors: use unqualified GetLibraryBook The new SortTags class lives in namespace Templates_ChapterFile_Tests but referenced Shared.GetLibraryBook(). The Shared class is in namespace TemplatesTests; the file's top-level `using static TemplatesTests.Shared;` brings the methods in unqualified, so drop the `Shared.` prefix to match the surrounding test conventions. Verified locally: SortTags tests pass (14/14), full project builds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 72e56713e..a6fa95e08 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -1523,7 +1523,7 @@ public class SortTags { static LibraryBookDto Book(string titleWithSubtitle, string title, string? seriesName = null) { - var dto = Shared.GetLibraryBook(seriesName is null ? null : [new SeriesDto(seriesName, "1", "sid")]); + var dto = GetLibraryBook(seriesName is null ? null : [new SeriesDto(seriesName, "1", "sid")]); dto.Title = title; dto.TitleWithSubtitle = titleWithSubtitle; return dto; @@ -1567,7 +1567,7 @@ public void TitleSort_case_insensitive() public void TitleSort_available_in_chapter_template() { Templates.TryGetTemplate<Templates.ChapterFileTemplate>("<title sort>", out var t).Should().BeTrue(); - var dto = Shared.GetLibraryBook(); + var dto = GetLibraryBook(); dto.TitleWithSubtitle = "The Silmarillion"; t.GetName(dto, new MultiConvertFileProperties { OutputFileName = string.Empty }) .Should().Be("Silmarillion");