diff --git a/Documentation/NamingTemplates.md b/Documentation/NamingTemplates.md index 184570ea..b364fa04 100644 --- a/Documentation/NamingTemplates.md +++ b/Documentation/NamingTemplates.md @@ -105,13 +105,13 @@ As an example, this folder template will place all Liberated podcasts into a "Po ## Series Formatters |Formatter|Description|Example Usage|Example Result| |-|-|-|-| -|\{N \| # \| ID\}|Formats the series using
the series part tags.
\{N\} = Series Name
\{#\} = Number order in series
\{ID\} = Audible Series ID

Default is \{N\}|``
``
``|Sherlock Holmes
Sherlock Holmes
Sherlock Holmes, 1, B08376S3R2| +|\{N \| # \| ID\}|Formats the series using
the series part tags.
\{N\} = Series Name
\{#\} = Number order in series
\{#:[Number_Formatter](#number-formatters)\} = Number order in series, formatted
\{ID\} = Audible Series ID

Default is \{N\}|``
``
``
``|Sherlock Holmes
Sherlock Holmes
Sherlock Holmes, 1-6, B08376S3R2
Sherlock Holmes, B08376S3R2, 01.0-06.0| ## Series List Formatters |Formatter|Description|Example Usage|Example Result| |-|-|-|-| |separator()|Speficy the text used to join
multiple series names.

Default is ", "|``|Sherlock Holmes; Some Other Series| -|format(\{N \| # \| ID\})|Formats the series properties
using the name series tags.
See [Series Formatter Usage](#series-formatters) above.|``separator(; )]>`
``|Sherlock Holmes, 1; Some Other Series, 1
herlock Holmes, B08376S3R2; Some Other Series, B000000000| +|format(\{N \| # \| ID\})|Formats the series properties
using the name series tags.
See [Series Formatter Usage](#series-formatters) above.|``separator(; )]>`
``|Sherlock Holmes, 1-6; Book Collection, 1
B08376S3R2-Sherlock Holmes, 01.0-06.0, B000000000-Book Collection, 01.0| |max(#)|Only use the first # of series

Default is all series|``|Sherlock Holmes| ## Name Formatters diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 0b761f6a..578c8ff5 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -26,7 +26,7 @@ namespace FileLiberator public string Language => LibraryBook.Book.Language; public string? AudibleProductId => LibraryBookDto.AudibleProductId; public string? SeriesName => LibraryBookDto.FirstSeries?.Name; - public string? SeriesNumber => LibraryBookDto.FirstSeries?.Number; + public string? SeriesNumber => LibraryBookDto.FirstSeries?.Order?.ToString(); public NAudio.Lame.LameConfig? LameConfig { get; } public string UserAgent => AudibleApi.Resources.Download_User_Agent; public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged; diff --git a/Source/LibationFileManager/Templates/SeriesDto.cs b/Source/LibationFileManager/Templates/SeriesDto.cs index cfd3d3fd..a2d7a867 100644 --- a/Source/LibationFileManager/Templates/SeriesDto.cs +++ b/Source/LibationFileManager/Templates/SeriesDto.cs @@ -1,27 +1,34 @@ using System; +using System.Text.RegularExpressions; #nullable enable namespace LibationFileManager.Templates; -public record SeriesDto : IFormattable +public partial record SeriesDto : IFormattable { public string Name { get; } - public string? Number { get; } + public SeriesOrder Order { get; } public string AudibleSeriesId { get; } public SeriesDto(string name, string? number, string audibleSeriesId) { Name = name; - Number = number; + Order = SeriesOrder.Parse(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(); + : FormatRegex().Replace(format, MatchEvaluator) + .Replace("{N}", Name) + .Replace("{ID}", AudibleSeriesId) + .Trim(); + + private string MatchEvaluator(Match match) + => Order?.ToString(match.Groups[1].Value, null) ?? ""; + + /// Format must have at least one of the string {N}, {#}, {ID} + [GeneratedRegex(@"{#(?:\:(.*?))?}")] + public static partial Regex FormatRegex(); } diff --git a/Source/LibationFileManager/Templates/SeriesListFormat.cs b/Source/LibationFileManager/Templates/SeriesListFormat.cs index 1127eaa5..9db3e441 100644 --- a/Source/LibationFileManager/Templates/SeriesListFormat.cs +++ b/Source/LibationFileManager/Templates/SeriesListFormat.cs @@ -12,6 +12,6 @@ internal partial class SeriesListFormat : IListFormat : IListFormat.Join(formatString, series); /// Format must have at least one of the string {N}, {#}, {ID} - [GeneratedRegex(@"[Ff]ormat\((.*?(?:{[N#]}|{ID})+.*?)\)")] + [GeneratedRegex(@"[Ff]ormat\((.*?(?:{#(?:\:.*?)?}|{N}|{ID})+.*?)\)")] public static partial Regex FormatRegex(); } diff --git a/Source/LibationFileManager/Templates/SeriesOrder.cs b/Source/LibationFileManager/Templates/SeriesOrder.cs new file mode 100644 index 00000000..ddf31a63 --- /dev/null +++ b/Source/LibationFileManager/Templates/SeriesOrder.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +#nullable enable +namespace LibationFileManager.Templates; + +public class SeriesOrder : IFormattable +{ + public object[] OrderParts { get; } + private SeriesOrder(object[] orderParts) + { + OrderParts = orderParts; + } + + public override string ToString() => ToString(null, null); + + /// + /// Use float formatters to format the number parts of the order. + /// + public string ToString(string? format, IFormatProvider? formatProvider) + => string.Concat(OrderParts.Select(p => p is float f ? f.ToString(format) : p.ToString())).Trim(); + + public static SeriesOrder Parse(string? order) + { + List parts = new(); + while (TryParseNumber(order, out var value, out var range)) + { + var prefix = order[..range.Start.Value]; + if(!string.IsNullOrWhiteSpace(prefix)) + parts.Add(prefix); + + parts.Add(value); + + order = order[range.End.Value..]; + } + + if (!string.IsNullOrWhiteSpace(order)) + parts.Add(order); + + return new(parts.ToArray()); + } + + /// + /// Try to parse any positive number from within the string (greedy). + /// + /// the string to search for a numeric value + /// If this function succeeds, the number that was found; otherwise zero. + /// If this function succeeds, the range of characters representing in ; otherwise default + /// True if a number was found; otherwise false. + private static bool TryParseNumber([NotNullWhen(true)] string? numString, out float value, out Range range) + { + value = 0; + if (string.IsNullOrWhiteSpace(numString)) + { + range = default; + return false; + } + + for (int s = 0; s < numString.Length; s++) + { + //Assume any valid number will begin with a digit. + //This way, leading dots and dashes will never be considered part of a number, so + //no negative series numbers and no fractional series numbers < 1 (unless preceded with a '0'). + if (!char.IsDigit(numString[s])) + continue; + + for (int e = numString.Length; e > s; e--) + { + //The float parser will succeed with trailing whitespace, + //but we want to preserve it in the final display string. + if (char.IsWhiteSpace(numString[e - 1])) + continue; + + var substring = numString[s..e]; + if (float.TryParse(substring, out value)) + { + range = new Range(s, e); + return true; + } + } + } + + range = default; + return false; + } +} diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 5b36ae1a..e67acd6f 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -271,7 +271,7 @@ namespace LibationFileManager.Templates { 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.SeriesNumber, lb => lb.FirstSeries?.Order, FormattableFormatter }, { TemplateTags.Language, lb => lb.Language }, //Don't allow formatting of LanguageShort { TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort }, diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index cb853d15..15da05ea 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -388,6 +388,7 @@ namespace TemplatesTests [DataRow("", "Series A")] [DataRow("", "Series A")] [DataRow("", "Series A, 1, B1")] + [DataRow("", "Series A, 01.0")] public void SeriesFormat_formatters(string template, string expected) { var bookDto = GetLibraryBook(); @@ -406,6 +407,30 @@ namespace TemplatesTests .Should().Be(expected); } + [TestMethod] + [DataRow("", "1-6", "1-6")] + [DataRow("", "1-6", "1.00-6.00")] + [DataRow("", "1-6", "1.00-6.00")] + [DataRow("", "1-6", "1.00-6.00")] + [DataRow("", "front 1-6 back", "front 1.00-6.00 back")] + [DataRow("", "front 1 - 6 back", "front 1.00 - 6.00 back")] + [DataRow("", "f.1", "f.1.00")] + [DataRow("", "f1g", "f1.00g")] + [DataRow("", " f1g ", "f1.00g")] + [DataRow("", "1", "1")] + [DataRow("", "1", "1")] + public void SeriesOrder_formatters(string template, string seriesOrder, string expected) + { + var bookDto = GetLibraryBook(); + bookDto.Series = [new("Series A", seriesOrder, "B1")]; + + Templates.TryGetTemplate(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)] @@ -496,7 +521,7 @@ namespace Templates_Other var sb = new System.Text.StringBuilder(); sb.Append('0', 300); var longText = sb.ToString(); - Assert.ThrowsException(() => NEW_GetValidFilename_FileNamingTemplate(baseDir, template, "my: book " + longText, "txt")); + Assert.ThrowsExactly(() => NEW_GetValidFilename_FileNamingTemplate(baseDir, template, "my: book " + longText, "txt")); } private class TemplateTag : ITemplateTag