From 3a4ab8089202ad5ef4fd69ecaa768bd3c200d5ef Mon Sep 17 00:00:00 2001 From: Mbucari Date: Fri, 10 Feb 2023 12:14:24 -0700 Subject: [PATCH] Add human name parsing and formatting to naming templates --- Documentation/NamingTemplates.md | 19 +++-- .../LiberatedStatusBatchManualDialog.axaml | 2 +- .../Dialogs/LocateAudiobooksDialog.axaml.cs | 2 +- .../LibationAvalonia/Views/MainWindow.axaml | 2 +- .../LibationFileManager.csproj | 1 + Source/LibationFileManager/Templates.cs | 72 ++++++++++++++++++- .../LibationFileManager/_InternalsVisible.cs | 1 - Source/LibationWinForms/Form1.Designer.cs | 2 +- .../TemplatesTests.cs | 23 ++++++ 9 files changed, 111 insertions(+), 13 deletions(-) delete mode 100644 Source/LibationFileManager/_InternalsVisible.cs diff --git a/Documentation/NamingTemplates.md b/Documentation/NamingTemplates.md index 4c0fea50..f056032d 100644 --- a/Documentation/NamingTemplates.md +++ b/Documentation/NamingTemplates.md @@ -10,6 +10,7 @@ These templates apply to both GUI and CLI. - [Conditional Tags](#conditional-tags) - [Tag Formatters](#tag-formatters) - [Text Formatters](#text-formatters) + - [Name List Formatters](#name-list-formatters) - [Integer Formatters](#integer-formatters) - [Date Formatters](#date-formatters) @@ -26,9 +27,9 @@ These tags will be replaced in the template with the audiobook's values. |\ **†**|Audible book ID (ASIN)|Text| |\|Full title|Text| |\|Title. Stop at first colon|Text| -|\<author\>|Author(s)|Text| +|\<author\>|Author(s)|Name List| |\<first author\>|First author|Text| -|\<narrator\>|Narrator(s)|Text| +|\<narrator\>|Narrator(s)|Name List| |\<first narrator\>|First narrator|Text| |\<series\>|Name of series|Text| |\<series#\>|Number order in series|Text| @@ -73,7 +74,7 @@ As an example, this folder template will place all Liberated podcasts into a "Po # Tag Formatters -**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type. +**Text**, **Name List**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type. ## Text Formatters |Formatter|Description|Example Usage|Example Result| @@ -81,12 +82,18 @@ As an example, this folder template will place all Liberated podcasts into a "Po |L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel| |U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET| +## Name List Formatters +|Formatter|Description|Example Usage|Example Result| +|-|-|-|-| +|separator()|Speficy the text used to join multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry| +|format(\{T \| F \| M \| L \| S\})|Formats the human name using the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\} |`<author[format({L}, {F}) separator(; )]>`|Doyle, Arthur; Fry, Stephen| +|sort(F \| M \| L)|Sorts the names by first, middle, or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle| +|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle| + ## Integer Formatters |Formatter|Description|Example Usage|Example Result| |-|-|-|-| -|# (a number)|Zero-pads the number|\<bitrate[4]\><br>\<series#[3]\><br>\<samplerate[6]\>|0128<br>001<br>044100| - -**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type. +|# (a number)|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100| ## Date Formatters Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings). diff --git a/Source/LibationAvalonia/Dialogs/LiberatedStatusBatchManualDialog.axaml b/Source/LibationAvalonia/Dialogs/LiberatedStatusBatchManualDialog.axaml index 9aa9cb95..bdb39d0c 100644 --- a/Source/LibationAvalonia/Dialogs/LiberatedStatusBatchManualDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/LiberatedStatusBatchManualDialog.axaml @@ -12,7 +12,7 @@ Icon="/Assets/libation.ico"> <Grid RowDefinitions="Auto,Auto,Auto"> - + <TextBlock Grid.Row="0" Margin="10,10,10,0" diff --git a/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs index 699d6384..598de80e 100644 --- a/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs @@ -46,7 +46,7 @@ namespace LibationAvalonia.Dialogs { tokenSource.Cancel(); //If this dialog is closed before it's completed, Closing is fired - //once for the form clising and again for the MessageBox closing. + //once for the form closing and again for the MessageBox closing. Closing -= LocateAudiobooksDialog_Closing; this.SaveSizeAndLocation(Configuration.Instance); } diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml index 6ec2e945..9fac32e2 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow.axaml @@ -53,7 +53,7 @@ <MenuItem IsVisible="{Binding MultipleAccounts}" IsEnabled="{Binding RemoveMenuItemsEnabled}" Click="removeSomeAccountsToolStripMenuItem_Click" Header="_Remove Books from Some Accounts" /> <Separator /> - <MenuItem Click="locateAudiobooksToolStripMenuItem_Click" Header="Locate Audiobooks" /> + <MenuItem Click="locateAudiobooksToolStripMenuItem_Click" Header="L_ocate Audiobooks" /> </MenuItem> diff --git a/Source/LibationFileManager/LibationFileManager.csproj b/Source/LibationFileManager/LibationFileManager.csproj index 90f7fe43..9f2ef651 100644 --- a/Source/LibationFileManager/LibationFileManager.csproj +++ b/Source/LibationFileManager/LibationFileManager.csproj @@ -6,6 +6,7 @@ <ItemGroup> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" /> + <PackageReference Include="NameParserSharp" Version="1.5.0" /> <PackageReference Include="Serilog.Exceptions" Version="8.4.0" /> </ItemGroup> diff --git a/Source/LibationFileManager/Templates.cs b/Source/LibationFileManager/Templates.cs index 01b1cf64..4e4c34c2 100644 --- a/Source/LibationFileManager/Templates.cs +++ b/Source/LibationFileManager/Templates.cs @@ -2,10 +2,12 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using AaxDecrypter; using Dinah.Core; using FileManager; using FileManager.NamingTemplate; +using NameParser; namespace LibationFileManager { @@ -68,6 +70,8 @@ namespace LibationFileManager Configuration.Instance.PropertyChanged += [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] (_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue); + + HumanName.Suffixes.Add("ret"); } #endregion @@ -198,9 +202,9 @@ namespace LibationFileManager { TemplateTags.Id, lb => lb.AudibleProductId, v => v }, { TemplateTags.Title, lb => lb.Title }, { TemplateTags.TitleShort, lb => getTitleShort(lb.Title) }, - { TemplateTags.Author, lb => lb.AuthorNames }, + { TemplateTags.Author, lb => lb.Authors, NameFormatter }, { TemplateTags.FirstAuthor, lb => lb.FirstAuthor }, - { TemplateTags.Narrator, lb => lb.NarratorNames }, + { TemplateTags.Narrator, lb => lb.Narrators, NameFormatter }, { TemplateTags.FirstNarrator, lb => lb.FirstNarrator }, { TemplateTags.Series, lb => lb.SeriesName }, { TemplateTags.SeriesNumber, lb => lb.SeriesNumber }, @@ -247,6 +251,70 @@ namespace LibationFileManager #region Tag Formatters + private static readonly string[] suffixes = { "introductions", "introduction", "adaptation", "translator", "contributor", "illustrator", "director", "foreword", "editor", "preface", "adaptor", "afterword", "interviewer", "introductions", "essay", "editor/introduction" }; + + private static string removeSuffix(string namesString) + { + foreach (var suffix in suffixes) + namesString = namesString.Replace($" - {suffix}", ""); + + return namesString.Replace('’', '\'').Replace(" - Ret.", ", Ret.").Trim(); + } + + //Format must have at least one of the string {T}, {F}, {M}, {L}, or {S} + private static readonly Regex FormatRegex = new(@"[Ff]ormat\((.*?(?:{[TFMLS]})+.*?)\)", RegexOptions.Compiled); + //Sort must have exactly one of the characters F, M, or L + private static readonly Regex SortRegex = new(@"[Ss]ort\(\s*?([FML])\s*?\)", RegexOptions.Compiled); + //Max must have a 1 or 2-digit number + private static readonly Regex MaxRegex = new(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)", RegexOptions.Compiled); + //Separator can be anything + private static readonly Regex SeparatorRegex = new(@"[Ss]eparator\((.*?)\)", RegexOptions.Compiled); + + private static string NameFormatter(ITemplateTag templateTag, IEnumerable<string> value, string formatString) + { + var names = value.Select(n => new HumanName(removeSuffix(n), Prefer.FirstOverPrefix)); + + var formatMatch = FormatRegex.Match(formatString); + string nameFormatString + = formatMatch.Success + ? formatMatch.Groups[1].Value + .Replace("{T}", "{0}") + .Replace("{F}", "{1}") + .Replace("{M}", "{2}") + .Replace("{L}", "{3}") + .Replace("{S}", "{4}") + : "{0} {1} {2} {3} {4}"; // T F M L S + + var maxMatch = MaxRegex.Match(formatString); + int maxNames = maxMatch.Success && int.TryParse(maxMatch.Groups[1].Value, out var max) ? int.Max(1, max) : int.MaxValue; + + var separatorMatch = SeparatorRegex.Match(formatString); + var separatorString = separatorMatch.Success ? separatorMatch.Groups[1].Value : ", "; + + var sortMatch = SortRegex.Match(formatString); + var sortedNames + = sortMatch.Success + ? ( + sortMatch.Groups[1].Value.ToUpper() == "F" ? names.OrderBy(n => n.First) + : sortMatch.Groups[1].Value.ToUpper() == "M" ? names.OrderBy(n => n.Middle) + : sortMatch.Groups[1].Value.ToUpper() == "L" ? names.OrderBy(n => n.Last) + : names + ) + : names; + + var formattedNames = string.Join( + separatorString, + sortedNames + .Take(int.Min(sortedNames.Count(), maxNames)) + .Select(n => string.Format(nameFormatString, n.Title, n.First, n.Middle, n.Last, n.Suffix).Trim()) + ); + + while (formattedNames.Contains(" ")) + formattedNames = formattedNames.Replace(" ", " "); + + return formattedNames; + } + private static string getTitleShort(string title) => title?.IndexOf(':') > 0 ? title.Substring(0, title.IndexOf(':')) : title; diff --git a/Source/LibationFileManager/_InternalsVisible.cs b/Source/LibationFileManager/_InternalsVisible.cs deleted file mode 100644 index d0bcf4c8..00000000 --- a/Source/LibationFileManager/_InternalsVisible.cs +++ /dev/null @@ -1 +0,0 @@ -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(LibationFileManager) + ".Tests")] \ No newline at end of file diff --git a/Source/LibationWinForms/Form1.Designer.cs b/Source/LibationWinForms/Form1.Designer.cs index d60e3e3f..56e99a98 100644 --- a/Source/LibationWinForms/Form1.Designer.cs +++ b/Source/LibationWinForms/Form1.Designer.cs @@ -569,7 +569,7 @@ // this.locateAudiobooksToolStripMenuItem.Name = "locateAudiobooksToolStripMenuItem"; this.locateAudiobooksToolStripMenuItem.Size = new System.Drawing.Size(247, 22); - this.locateAudiobooksToolStripMenuItem.Text = "Locate Audiobooks"; + this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks"; this.locateAudiobooksToolStripMenuItem.Click += new System.EventHandler(this.locateAudiobooksToolStripMenuItem_Click); // // toolStripSeparator3 diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 394c7ecc..f3ffa23e 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -238,6 +238,29 @@ namespace TemplatesTests .Should().Be(expected); } + [TestMethod] + [DataRow("<author>", "Arthur Conan Doyle, Stephen Fry")] + [DataRow("<author[]>", "Arthur Conan Doyle, Stephen Fry")] + [DataRow("<author[sort(F)]>", "Arthur Conan Doyle, Stephen Fry")] + [DataRow("<author[sort(L)]>", "Arthur Conan Doyle, Stephen Fry")] + [DataRow("<author[sort(M)]>", "Stephen Fry, Arthur Conan Doyle")] + [DataRow("<author[sort(m)]>", "Arthur Conan Doyle, Stephen Fry")] + [DataRow("<author [ max( 1 ) ]>", "Arthur Conan Doyle")] + [DataRow("<author[max(2)]>", "Arthur Conan Doyle, Stephen Fry")] + [DataRow("<author[max(3)]>", "Arthur Conan Doyle, Stephen Fry")] + [DataRow("<author[format({L}, {F})]>", "Doyle, Arthur, Fry, Stephen")] + [DataRow("<author[format({f}, {l})]>", "Arthur Conan Doyle, Stephen Fry")] + [DataRow("<author[format(First={F}, Last={L})]>", "First=Arthur, Last=Doyle, First=Stephen, Last=Fry")] + [DataRow("<author[format({L}, {F}) separator( - )]>", "Doyle, Arthur - Fry, Stephen")] + public void NameFormat(string template, string expected) + { + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + fileTemplate + .GetFilename(GetLibraryBook(), "", "", 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)]