Improve series order parsing and formatting

This commit is contained in:
Michael Bucari-Tovo 2025-08-14 15:10:53 -06:00
parent d0f00f3f1e
commit e1d789ccdc
7 changed files with 134 additions and 14 deletions

View File

@ -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<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|
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series, formatted<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`<hr>`<first series[{N}, {ID}, {#:00.0}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1-6, B08376S3R2<hr>Sherlock Holmes, B08376S3R2, 01.0-06.0|
## 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|
|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|
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
## Name Formatters

View File

@ -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;

View File

@ -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) ?? "";
/// <summary> Format must have at least one of the string {N}, {#}, {ID} </summary>
[GeneratedRegex(@"{#(?:\:(.*?))?}")]
public static partial Regex FormatRegex();
}

View File

@ -12,6 +12,6 @@ internal partial class SeriesListFormat : IListFormat<SeriesListFormat>
: IListFormat<SeriesListFormat>.Join(formatString, series);
/// <summary> Format must have at least one of the string {N}, {#}, {ID} </summary>
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{[N#]}|{ID})+.*?)\)")]
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{#(?:\:.*?)?}|{N}|{ID})+.*?)\)")]
public static partial Regex FormatRegex();
}

View File

@ -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);
/// <summary>
/// Use float formatters to format the number parts of the order.
/// </summary>
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<object> 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());
}
/// <summary>
/// Try to parse any positive number from within the string (greedy).
/// </summary>
/// <param name="numString">the string to search for a numeric value</param>
/// <param name="value">If this function succeeds, the number that was found; otherwise zero.</param>
/// <param name="range">If this function succeeds, the range of characters representing <paramref name="value"/> in <paramref name="numString"/>; otherwise default</param>
/// <returns>True if a number was found; otherwise false.</returns>
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;
}
}

View File

@ -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 },

View File

@ -388,6 +388,7 @@ namespace TemplatesTests
[DataRow("<first series>", "Series A")]
[DataRow("<first series[]>", "Series A")]
[DataRow("<first series[{N}, {#}, {ID}]>", "Series A, 1, B1")]
[DataRow("<first series[{N}, {#:00.0}]>", "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("<first series[{#}]>", "1-6", "1-6")]
[DataRow("<series[format({#:F2})]>", "1-6", "1.00-6.00")]
[DataRow("<first series[{#:F2}]>", "1-6", "1.00-6.00")]
[DataRow("<series#[F2]>", "1-6", "1.00-6.00")]
[DataRow("<series#[F2]>", "front 1-6 back", "front 1.00-6.00 back")]
[DataRow("<series#[F2]>", "front 1 - 6 back", "front 1.00 - 6.00 back")]
[DataRow("<series#[F2]>", "f.1", "f.1.00")]
[DataRow("<series#[F2]>", "f1g", "f1.00g")]
[DataRow("<series#[F2]>", " f1g ", "f1.00g")]
[DataRow("<series#[]>", "1", "1")]
[DataRow("<series#>", "1", "1")]
public void SeriesOrder_formatters(string template, string seriesOrder, string expected)
{
var bookDto = GetLibraryBook();
bookDto.Series = [new("Series A", seriesOrder, "B1")];
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)]
@ -496,7 +521,7 @@ namespace Templates_Other
var sb = new System.Text.StringBuilder();
sb.Append('0', 300);
var longText = sb.ToString();
Assert.ThrowsException<PathTooLongException>(() => NEW_GetValidFilename_FileNamingTemplate(baseDir, template, "my: book " + longText, "txt"));
Assert.ThrowsExactly<PathTooLongException>(() => NEW_GetValidFilename_FileNamingTemplate(baseDir, template, "my: book " + longText, "txt"));
}
private class TemplateTag : ITemplateTag