diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index a515bdb8..5c534a36 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -71,7 +71,7 @@ public static class UtilityExtensions IsPodcastParent = libraryBook.Book.IsEpisodeParent(), IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(), - LengthInMinutes = libraryBook.Book.LengthInMinutes, + LengthInMinutes = TimeSpan.FromMinutes(libraryBook.Book.LengthInMinutes), Language = libraryBook.Book.Language?.Trim(), Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString, BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate, diff --git a/Source/FileManager/NamingTemplate/CommonFormatters.cs b/Source/FileManager/NamingTemplate/CommonFormatters.cs index e68a492c..5fea2f7b 100644 --- a/Source/FileManager/NamingTemplate/CommonFormatters.cs +++ b/Source/FileManager/NamingTemplate/CommonFormatters.cs @@ -8,6 +8,7 @@ namespace FileManager.NamingTemplate; public static partial class CommonFormatters { public const string DefaultDateFormat = "yyyy-MM-dd"; + public const string DefaultTimeSpanFormat = "MMM"; public delegate TFormatted? PropertyFormatter(ITemplateTag templateTag, TProperty? value, string? formatString, CultureInfo? culture); @@ -107,38 +108,39 @@ public static partial class CommonFormatters return new string('0', zeroPad) + strValue; } - public static string MinutesFormatter(ITemplateTag templateTag, int value, string? formatString, CultureInfo? culture) + public static string MinutesFormatter(ITemplateTag templateTag, TimeSpan value, string? formatString, CultureInfo? culture) { culture ??= CultureInfo.CurrentCulture; - if (string.IsNullOrWhiteSpace(formatString)) - return value.ToString(culture); + formatString ??= DefaultTimeSpanFormat; - var timeSpan = TimeSpan.FromMinutes(value); - var result = formatString; + // the format string is build as a custom format for TimeSpans. Time portion like 'h' and 'm' are used to format the minutes and hours part of the TimeSpan. + // They are limited by the next greater domain. So 'h' will be between 0 and 23, 'm' between 0 and 59. 'd' will be the total number of days. + // To get the total timespan display in terms of total hours or total minutes, we allow the format string to include number formats with uppercase D, H or M. + // As there might be up to three numbers shown in the format string, we distinguish between total days, hours and minutes with uppercase D, H and M instead of zeros. + // A format "#,##0'minutes'" would for example become "#,##M'minutes'". If you combine them the lower units will be reduced by the higher units. + // "D'days and'#,##0'minutes'" will show 1,439 minutes at maximum. + // In the first step we search for number formats with uppercase D, H or M, format their values as number and replace them as quoted strings in the format string. + var timeSpanForTotal = value; + formatString = FormatAsNumberIntoTemplate(templateTag, formatString, culture, ref timeSpanForTotal, RegexMinutesTotalD(), TimeSpan.TicksPerDay); + formatString = FormatAsNumberIntoTemplate(templateTag, formatString, culture, ref timeSpanForTotal, RegexMinutesTotalH(), TimeSpan.TicksPerHour); + formatString = FormatAsNumberIntoTemplate(templateTag, formatString, culture, ref timeSpanForTotal, RegexMinutesTotalM(), TimeSpan.TicksPerMinute); - // replace all placeholders with formatted values - result = RegexMinutesD().Replace(result, m => + // The formatString should now be a valid TimeSpan format string. + return value.ToString(formatString, culture); + } + + private static string FormatAsNumberIntoTemplate(ITemplateTag templateTag, string formatString, CultureInfo culture, ref TimeSpan timeSpanForTotal, Regex regex, long ticks) + { + var total = timeSpanForTotal.Ticks / ticks; + var matched = false; + var result = regex.Replace(formatString, m => { - var val = (int)timeSpan.TotalDays; - timeSpan = timeSpan.Subtract(TimeSpan.FromDays(val)); - - return FloatFormatter(templateTag, val, m.Groups["format"].Value, culture); + matched = true; + var numPattern = RegexTimeStampToNumberPattern().Replace(m.Groups["format"].Value, "0"); + var formatted = FloatFormatter(templateTag, total, numPattern, culture); + return $"'{formatted}'"; }); - result = RegexMinutesH().Replace(result, m => - { - var val = (int)timeSpan.TotalHours; - timeSpan = timeSpan.Subtract(TimeSpan.FromHours(val)); - - return FloatFormatter(templateTag, val, m.Groups["format"].Value, culture); - }); - result = RegexMinutesM().Replace(result, m => - { - var val = (int)timeSpan.TotalMinutes; - timeSpan = timeSpan.Subtract(TimeSpan.FromMinutes(val)); - - return FloatFormatter(templateTag, val, m.Groups["format"].Value, culture); - }); - + if (matched) timeSpanForTotal = TimeSpan.FromTicks(timeSpanForTotal.Ticks % ticks); return result; } @@ -155,13 +157,38 @@ public static partial class CommonFormatters return StringFormatter(templateTag, language, "3u", culture); } - // Regex to find patterns like {D:3}, {h:4}, {m} - [GeneratedRegex("""\{D(?::(?(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)] - private static partial Regex RegexMinutesD(); + // These search for number formats with all notions of escaping and quoting, but all zeros replaced with D, H, or M to indicate that they should be replaced with the total number of + // days hours or minutes in the timespan (not just the minutes part). Only one of them is written commented. The others are identical except for the letter D, H or M. + // I most cases this regex will only find a straight bunch of D's, H's or M's, but it also allows for more complex formats. + [GeneratedRegex(""" + (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # + (?<=\G(?: # We lookbehind up to the start or the end of the last match for a number format. + \\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + | '(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character + | "(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character + | . # - match any character. This will not catch the number format at first. Because ... + ) *? ) # With *? the pattern above tries not to consume the number format. + (? # We capture the whole number format in a group called ''. + (?:\#[\#,.]*)? # - For grouping a number format may start with `#` and grouping hints `,` or even a decimal point `.`. + D # - At least one unescaped, unquoted uppercase D must be included in the format to indicate that this is a total days format. + (?:(?: # - Before further D's, there may be any combination of escaped characters and quoted strings. + \\. # - '\' escapes allways the next character. Especially further '\' and the closing ']' + | '(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character + | "(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character + )* [\#,.%‰D]+ # After escaped characters and quoted strings, there needs to be at least one more real number format character (which may be D as well). + )* # This may extend the format several times, for example in `D\:DD` or `D' days 'D\-D`. + (?:[Ee][+-]?0+)? # The original number format may end with an optional scientific notation part. This is also optional. + ) # end of capture group '' + """)] + private static partial Regex RegexMinutesTotalD(); - [GeneratedRegex("""\{H(?::(?(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)] - private static partial Regex RegexMinutesH(); + [GeneratedRegex("""(?<=\G(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?)(?(?:#[#,.]*)?H(?:(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*")*[H%‰#,.]+)*(?:[Ee][+-]?0+)?)""")] + private static partial Regex RegexMinutesTotalH(); - [GeneratedRegex("""\{M(?::(?(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)] - private static partial Regex RegexMinutesM(); + [GeneratedRegex("""(?<=\G(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?)(?(?:#[#,.]*)?M(?:(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*")*[M%‰#,.]+)*(?:[Ee][+-]?0+)?)""")] + private static partial Regex RegexMinutesTotalM(); + + // Capture all D H or M characters in the number format, so that they can be replaced with zeros. + [GeneratedRegex("""(?<=\G(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?)[DHM]""")] + private static partial Regex RegexTimeStampToNumberPattern(); } \ No newline at end of file diff --git a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 7c5f8136..3e3bdb5f 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -188,6 +188,7 @@ public partial class ConditionalTagCollection(bool caseSensitive = true) null => false, IEnumerable e => checkItem(e.Count(), culture), string s => checkItem(s.Length, culture), + TimeSpan ts => checkItem(ts.TotalMinutes, culture), _ => checkItem(v, culture) } : (v, culture) => v switch diff --git a/Source/LibationFileManager/Templates/LibraryBookDto.cs b/Source/LibationFileManager/Templates/LibraryBookDto.cs index 9e5e7bc1..f935300b 100644 --- a/Source/LibationFileManager/Templates/LibraryBookDto.cs +++ b/Source/LibationFileManager/Templates/LibraryBookDto.cs @@ -27,7 +27,7 @@ public class BookDto public bool IsPodcastParent { get; set; } public bool IsPodcast { get; set; } - public int LengthInMinutes { get; set; } + public TimeSpan LengthInMinutes { get; set; } public int? BitRate { get; set; } public int? SampleRate { get; set; } public int? Channels { get; set; } diff --git a/Source/_Tests/FileManager.Tests/CommonFormattersTests.cs b/Source/_Tests/FileManager.Tests/CommonFormattersTests.cs index 1808a3b5..a8aee2d4 100644 --- a/Source/_Tests/FileManager.Tests/CommonFormattersTests.cs +++ b/Source/_Tests/FileManager.Tests/CommonFormattersTests.cs @@ -34,8 +34,8 @@ public class CommonFormattersTests { // GIVEN var templateTag = new TemplateTag { TagName = "MINUTES" }; - var value = 0; - var format = "{H}:{M}"; + var value = TimeSpan.FromMinutes(0); + var format = @"h\:m"; // WHEN var result = CommonFormatters.MinutesFormatter(templateTag, value, format, CultureInfo.InvariantCulture); @@ -49,8 +49,8 @@ public class CommonFormattersTests { // GIVEN var templateTag = new TemplateTag { TagName = "MINUTES" }; - var value = 1440; // 24 hours - var format = "{D}d {H}h {M}m"; + var value = TimeSpan.FromHours(24); + var format = @"d'd 'h'h 'm\m"; // WHEN var result = CommonFormatters.MinutesFormatter(templateTag, value, format, CultureInfo.InvariantCulture); @@ -64,8 +64,8 @@ public class CommonFormattersTests { // GIVEN var templateTag = new TemplateTag { TagName = "MINUTES" }; - var value = 3000; // 50 hours - var format = "{D}d {H}h {M}m"; + var value = TimeSpan.FromHours(50); // 50 hours + var format = @"d'd 'h'h 'm\m"; // WHEN var result = CommonFormatters.MinutesFormatter(templateTag, value, format, CultureInfo.InvariantCulture); diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 1f20042b..9fa6d131 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -57,7 +57,7 @@ namespace TemplatesTests Codec = @"AAC[LC]\MP3", // special chars added FileVersion = null, // explicitly null LibationVersion = "", // explicitly empty string - LengthInMinutes = 100, + LengthInMinutes = TimeSpan.FromMinutes(100), IsAbridged = true, Tags = [new StringDto("Tag1"), new StringDto("Tag2"), new StringDto("Tag3")], }; @@ -194,26 +194,29 @@ namespace TemplatesTests [TestMethod] [DataRow("", 100, "100")] - [DataRow("", 100, "100")] - [DataRow("", 100, "100")] - [DataRow("", 100, "1-40")] - [DataRow("", 100, "01-40")] - [DataRow("", 100, "0.01-40")] - [DataRow("", 100, "00d01h40m")] - [DataRow("", 100, "0 days, 1 hours, 40 minutes")] - [DataRow("", 2000, "33-20")] - [DataRow("", 2000, "001-009-020")] - [DataRow("", 2000, "20-9-1")] - [DataRow("", 100, "0-100")] - [DataRow("", 1500, "1-60")] - [DataRow("", 2000, "1-560")] - [DataRow("", 2880, "2-0")] - [DataRow("", 1500, "01-60")] - [DataRow(@"", 2000, "1-005{60}")] + [DataRow("", 100, "100")] + [DataRow("", 100, "100")] + [DataRow(@"", 100, "1-40")] + [DataRow(@"", 100, "01-100")] + [DataRow(@"", 100, "40 40 40")] + [DataRow(@"", 100, "%0 1 00")] + [DataRow(@"", 100, "0.01-100")] + [DataRow(@"", 100, "00d01h40m")] + [DataRow("""""", 100, "0[days], 1(hours), 40{minutes}")] + [DataRow(@"", 2000, "33-20")] + [DataRow(@"", 2000, "001-009-020")] + [DataRow(@"", 2000, "20-9-1")] + [DataRow(@"", 100, "0-100")] + [DataRow(@"", 1500, "1-60")] + [DataRow(@"", 2000, "1-560")] + [DataRow(@"", 2880, "2-0")] + [DataRow(@"", 1500, "01-60")] + [DataRow(@"", 2000, "1-005{60}")] + [DataRow(@"", 123456789, "8.573,30E1-0.021,00-9")] public void MinutesFormat(string template, int minutes, string expected) { var bookDto = GetLibraryBook(); - bookDto.LengthInMinutes = minutes; + bookDto.LengthInMinutes = TimeSpan.FromMinutes(minutes); Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); fileTemplate.GetFilename(bookDto, "", "", Replacements).PathWithoutPrefix.Should().Be(expected);