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/AppScaffolding/UpgradeProperties.cs b/Source/AppScaffolding/UpgradeProperties.cs index 506255b2..aa042b52 100644 --- a/Source/AppScaffolding/UpgradeProperties.cs +++ b/Source/AppScaffolding/UpgradeProperties.cs @@ -1,11 +1,11 @@ -using System; +using NPOI.XWPF.UserModel; +using System; using System.Text.RegularExpressions; namespace AppScaffolding { - public record UpgradeProperties + public partial record UpgradeProperties { - private static readonly Regex linkstripper = new Regex(@"\[(.*)\]\(.*\)"); public string ZipUrl { get; } public string HtmlUrl { get; } public string ZipName { get; } @@ -18,17 +18,10 @@ namespace AppScaffolding HtmlUrl = htmlUrl; ZipUrl = zipUrl; LatestRelease = latestRelease; - Notes = stripMarkdownLinks(notes); + Notes = LinkStripRegex().Replace(notes, "$1"); } - private string stripMarkdownLinks(string body) - { - body = body.Replace(@"\", ""); - var matches = linkstripper.Matches(body); - foreach (Match match in matches) - body = body.Replace(match.Groups[0].Value, match.Groups[1].Value); - - return body; - } + [GeneratedRegex(@"\[(.*)\]\(.*\)")] + private static partial Regex LinkStripRegex(); } } diff --git a/Source/DataLayer/EfClasses/UserDefinedItem.cs b/Source/DataLayer/EfClasses/UserDefinedItem.cs index 91bf236b..d34c45e7 100644 --- a/Source/DataLayer/EfClasses/UserDefinedItem.cs +++ b/Source/DataLayer/EfClasses/UserDefinedItem.cs @@ -20,7 +20,7 @@ namespace DataLayer PartialDownload = 0x1000 } - public class UserDefinedItem + public partial class UserDefinedItem { internal int BookId { get; private set; } public Book Book { get; private set; } @@ -51,18 +51,23 @@ namespace DataLayer public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries); #region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen - // only legal chars are letters numbers underscores and separating whitespace - // - // technically, the only char.s which aren't easily supported are \ [ ] - // however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character) - // it's easy to expand whitelist as needed - // for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates - // - // there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score - // full list of characters which must be escaped: - // + - && || ! ( ) { } [ ] ^ " ~ * ? : \ - static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled); - private static string sanitize(string input) + + /// <summary> + /// only legal chars are letters numbers underscores and separating whitespace + /// + /// technically, the only char.s which aren't easily supported are \ [ ] + /// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character) + /// it's easy to expand whitelist as needed + /// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates + /// + /// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score + /// full list of characters which must be escaped: + /// + - && || ! ( ) { } [ ] ^ " ~ * ? : \ + /// </summary> + + [GeneratedRegex(@"[^\w\d\s_]")] + private static partial Regex IllegalCharacterRegex(); + private static string sanitize(string input) { if (string.IsNullOrWhiteSpace(input)) return ""; @@ -73,9 +78,9 @@ namespace DataLayer // assume a hyphen is supposed to be an underscore .Replace("-", "_"); - var unique = regex - // turn illegal characters into a space. this will also take care of turning new lines into spaces - .Replace(str, " ") + var unique = IllegalCharacterRegex() + // turn illegal characters into a space. this will also take care of turning new lines into spaces + .Replace(str, " ") // split and remove excess spaces .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) // de-dup diff --git a/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml b/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml new file mode 100644 index 00000000..79ac91bb --- /dev/null +++ b/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml @@ -0,0 +1,30 @@ +<Window xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450" + Width="600" Height="450" + x:Class="LibationAvalonia.Dialogs.LocateAudiobooksDialog" + Title="Locate Audiobooks" + WindowStartupLocation="CenterOwner" + Icon="/Assets/libation.ico"> + + <Grid Margin="5" ColumnDefinitions="*,Auto" RowDefinitions="Auto,*"> + <TextBlock Grid.Column="0" Text="Found Audiobooks" /> + <StackPanel Grid.Column="1" Orientation="Horizontal"> + + <TextBlock Text="IDs Found: " /> + <TextBlock Text="{Binding FoundAsins}" /> + </StackPanel> + <ListBox Margin="0,5,0,0" Grid.Row="1" Grid.ColumnSpan="2" Name="foundAudiobooksLB" Items="{Binding FoundFiles}" AutoScrollToSelectedItem="true"> + <ListBox.ItemTemplate> + <DataTemplate> + <Grid ColumnDefinitions="Auto,*"> + <TextBlock Grid.Column="0" Margin="0,0,10,0" Text="{Binding Item1}" /> + <TextBlock Grid.Column="1" Text="{Binding Item2}" /> + </Grid> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> + </Grid> +</Window> diff --git a/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs new file mode 100644 index 00000000..688b845b --- /dev/null +++ b/Source/LibationAvalonia/Dialogs/LocateAudiobooksDialog.axaml.cs @@ -0,0 +1,115 @@ +using ApplicationServices; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage.FileIO; +using DataLayer; +using LibationAvalonia.ViewModels; +using LibationFileManager; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace LibationAvalonia.Dialogs +{ + public partial class LocateAudiobooksDialog : DialogWindow + { + private event EventHandler<FilePathCache.CacheEntry> FileFound; + private readonly CancellationTokenSource tokenSource = new(); + private readonly List<string> foundAsins = new(); + private readonly LocatedAudiobooksViewModel _viewModel; + public LocateAudiobooksDialog() + { + InitializeComponent(); + + DataContext = _viewModel = new(); + this.RestoreSizeAndLocation(Configuration.Instance); + + if (Design.IsDesignMode) + { + _viewModel.FoundFiles.Add(new("[0000001]", "Filename 1.m4b")); + _viewModel.FoundFiles.Add(new("[0000002]", "Filename 2.m4b")); + } + else + { + Opened += LocateAudiobooksDialog_Opened; + FileFound += LocateAudiobooks_FileFound; + Closing += LocateAudiobooksDialog_Closing; + } + } + + private void LocateAudiobooksDialog_Closing(object sender, System.ComponentModel.CancelEventArgs e) + { + tokenSource.Cancel(); + //If this dialog is closed before it's completed, Closing is fired + //once for the form closing and again for the MessageBox closing. + Closing -= LocateAudiobooksDialog_Closing; + this.SaveSizeAndLocation(Configuration.Instance); + } + + private void LocateAudiobooks_FileFound(object sender, FilePathCache.CacheEntry e) + { + var newItem = new Tuple<string,string>($"[{e.Id}]", Path.GetFileName(e.Path)); + _viewModel.FoundFiles.Add(newItem); + foundAudiobooksLB.SelectedItem = newItem; + + if (!foundAsins.Any(asin => asin == e.Id)) + { + foundAsins.Add(e.Id); + _viewModel.FoundAsins = foundAsins.Count; + } + } + + private async void LocateAudiobooksDialog_Opened(object sender, EventArgs e) + { + var folderPicker = new FolderPickerOpenOptions + { + Title = "Select the folder to search for audiobooks", + AllowMultiple = false, + SuggestedStartLocation = new BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix) + }; + + var selectedFolder = await StorageProvider.OpenFolderPickerAsync(folderPicker); + + if (selectedFolder.FirstOrDefault().TryGetUri(out var uri) is not true || !Directory.Exists(uri.LocalPath)) + { + await CancelAndCloseAsync(); + return; + } + + using var context = DbContexts.GetContext(); + + await foreach (var book in AudioFileStorage.FindAudiobooksAsync(uri.LocalPath, tokenSource.Token)) + { + try + { + FilePathCache.Insert(book); + + var lb = context.GetLibraryBook_Flat_NoTracking(book.Id); + if (lb.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated) + await Task.Run(() => lb.UpdateBookStatus(LiberatedStatus.Liberated)); + + FileFound?.Invoke(this, book); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Error adding found audiobook file to Libation. {@audioFile}", book); + } + } + + await MessageBox.Show(this, $"Libation has found {foundAsins.Count} unique audiobooks and added them to its database. ", $"Found {foundAsins.Count} Audiobooks"); + await SaveAndCloseAsync(); + } + } + + public class LocatedAudiobooksViewModel : ViewModelBase + { + private int _foundAsins = 0; + public AvaloniaList<Tuple<string, string>> FoundFiles { get; } = new(); + public int FoundAsins { get => _foundAsins; set => this.RaiseAndSetIfChanged(ref _foundAsins, value); } + } +} diff --git a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml index a911a335..07a617f9 100644 --- a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml +++ b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml @@ -35,13 +35,13 @@ </DockPanel.Styles> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom"> <Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5"> - <TextBlock VerticalAlignment="Center" Text="{Binding Button1Text}"/> + <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button1Text}"/> </Button> <Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5"> - <TextBlock VerticalAlignment="Center" Text="{Binding Button2Text}"/> + <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button2Text}"/> </Button> <Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Content="Cancel" Margin="5"> - <TextBlock VerticalAlignment="Center" Text="{Binding Button3Text}"/> + <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button3Text}"/> </Button> </StackPanel> </DockPanel> diff --git a/Source/LibationAvalonia/MessageBox.cs b/Source/LibationAvalonia/MessageBox.cs index dee03e8d..a1ee276f 100644 --- a/Source/LibationAvalonia/MessageBox.cs +++ b/Source/LibationAvalonia/MessageBox.cs @@ -154,6 +154,7 @@ Libation. private static async Task<DialogResult> ShowCoreAsync(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) { + owner = owner?.IsLoaded is true ? owner : null; var dialog = await Dispatcher.UIThread.InvokeAsync(() => CreateMessageBox(owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition)); return await DisplayWindow(dialog, owner); diff --git a/Source/LibationAvalonia/Views/MainWindow.Export.cs b/Source/LibationAvalonia/Views/MainWindow.Export.cs index 24407f90..6759b09d 100644 --- a/Source/LibationAvalonia/Views/MainWindow.Export.cs +++ b/Source/LibationAvalonia/Views/MainWindow.Export.cs @@ -1,6 +1,7 @@ using ApplicationServices; using Avalonia.Controls; using Avalonia.Platform.Storage; +using FileManager; using LibationFileManager; using System; using System.Linq; @@ -20,14 +21,14 @@ namespace LibationAvalonia.Views { Title = "Where to export Library", SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix), - SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}.xlsx", + SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}", DefaultExtension = "xlsx", ShowOverwritePrompt = true, FileTypeChoices = new FilePickerFileType[] { - new("Excel Workbook (*.xlsx)") { Patterns = new[] { "xlsx" } }, - new("CSV files (*.csv)") { Patterns = new[] { "csv" } }, - new("JSON files (*.json)") { Patterns = new[] { "json" } }, + new("Excel Workbook (*.xlsx)") { Patterns = new[] { "*.xlsx" } }, + new("CSV files (*.csv)") { Patterns = new[] { "*.csv" } }, + new("JSON files (*.json)") { Patterns = new[] { "*.json" } }, new("All files (*.*)") { Patterns = new[] { "*" } }, } }; @@ -36,17 +37,17 @@ namespace LibationAvalonia.Views if (selectedFile?.TryGetUri(out var uri) is not true) return; - var ext = System.IO.Path.GetExtension(uri.LocalPath); + var ext = FileUtility.GetStandardizedExtension(System.IO.Path.GetExtension(uri.LocalPath)); switch (ext) { - case "xlsx": // xlsx + case ".xlsx": // xlsx default: LibraryExporter.ToXlsx(uri.LocalPath); break; - case "csv": // csv + case ".csv": // csv LibraryExporter.ToCsv(uri.LocalPath); break; - case "json": // json + case ".json": // json LibraryExporter.ToJson(uri.LocalPath); break; } diff --git a/Source/LibationAvalonia/Views/MainWindow.ScanManual.cs b/Source/LibationAvalonia/Views/MainWindow.ScanManual.cs index acd9602e..a94984c4 100644 --- a/Source/LibationAvalonia/Views/MainWindow.ScanManual.cs +++ b/Source/LibationAvalonia/Views/MainWindow.ScanManual.cs @@ -1,6 +1,7 @@ using ApplicationServices; using AudibleUtilities; using Avalonia.Controls; +using LibationAvalonia.Dialogs; using LibationFileManager; using System; using System.Collections.Generic; @@ -77,5 +78,11 @@ namespace LibationAvalonia.Views ex); } } + + private async void locateAudiobooksToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + var locateDialog = new LocateAudiobooksDialog(); + await locateDialog.ShowDialog(this); + } } } diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml index 12445243..9fac32e2 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow.axaml @@ -51,6 +51,9 @@ <MenuItem IsVisible="{Binding OneAccount}" IsEnabled="{Binding RemoveMenuItemsEnabled}" Click="removeLibraryBooksToolStripMenuItem_Click" Header="_Remove Library Books" /> <MenuItem IsVisible="{Binding MultipleAccounts}" IsEnabled="{Binding RemoveMenuItemsEnabled}" Click="removeAllAccountsToolStripMenuItem_Click" Header="_Remove Books from All Accounts" /> <MenuItem IsVisible="{Binding MultipleAccounts}" IsEnabled="{Binding RemoveMenuItemsEnabled}" Click="removeSomeAccountsToolStripMenuItem_Click" Header="_Remove Books from Some Accounts" /> + + <Separator /> + <MenuItem Click="locateAudiobooksToolStripMenuItem_Click" Header="L_ocate Audiobooks" /> </MenuItem> diff --git a/Source/LibationFileManager/AudibleFileStorage.cs b/Source/LibationFileManager/AudibleFileStorage.cs index f2cbda41..ef07230d 100644 --- a/Source/LibationFileManager/AudibleFileStorage.cs +++ b/Source/LibationFileManager/AudibleFileStorage.cs @@ -2,7 +2,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; +using Dinah.Core; +using System.Threading.Tasks; +using System.Threading; using FileManager; namespace LibationFileManager @@ -104,8 +108,14 @@ namespace LibationFileManager private static BackgroundFileSystem BookDirectoryFiles { get; set; } private static object bookDirectoryFilesLocker { get; } = new(); + private static EnumerationOptions enumerationOptions { get; } = new() + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + MatchCasing = MatchCasing.CaseInsensitive + }; - protected override LongPath GetFilePathCustom(string productId) + protected override LongPath GetFilePathCustom(string productId) => GetFilePathsCustom(productId).FirstOrDefault(); protected override List<LongPath> GetFilePathsCustom(string productId) @@ -122,5 +132,40 @@ namespace LibationFileManager public void Refresh() => BookDirectoryFiles.RefreshFiles(); public LongPath GetPath(string productId) => GetFilePath(productId); + + public static async IAsyncEnumerable<FilePathCache.CacheEntry> FindAudiobooksAsync(LongPath searchDirectory, [EnumeratorCancellation] CancellationToken cancellationToken) + { + ArgumentValidator.EnsureNotNull(searchDirectory, nameof(searchDirectory)); + + foreach (LongPath path in Directory.EnumerateFiles(searchDirectory, "*.M4B", enumerationOptions)) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + FilePathCache.CacheEntry audioFile = default; + + try + { + using var fileStream = File.OpenRead(path); + + var mp4File = await Task.Run(() => new AAXClean.Mp4File(fileStream), cancellationToken); + + if (mp4File?.AppleTags?.Asin is not null) + audioFile = new FilePathCache.CacheEntry(mp4File.AppleTags.Asin, FileType.Audio, path); + + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Error checking for asin in {@file}", path); + } + finally + { + GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true); + } + + if (audioFile is not null) + yield return audioFile; + } + } } } diff --git a/Source/LibationFileManager/FilePathCache.cs b/Source/LibationFileManager/FilePathCache.cs index 56eddddb..78cd990e 100644 --- a/Source/LibationFileManager/FilePathCache.cs +++ b/Source/LibationFileManager/FilePathCache.cs @@ -86,7 +86,11 @@ namespace LibationFileManager public static void Insert(string id, string path) { var type = FileTypes.GetFileTypeFromPath(path); - var entry = new CacheEntry(id, type, path); + Insert(new CacheEntry(id, type, path)); + } + + public static void Insert(CacheEntry entry) + { cache.Add(entry); Inserted?.Invoke(null, entry); save(); 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..f714637e 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 { @@ -17,7 +19,7 @@ namespace LibationFileManager static abstract IEnumerable<TagCollection> TagCollections { get; } } - public abstract class Templates + public abstract partial class Templates { public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\"; public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>"; @@ -68,6 +70,9 @@ namespace LibationFileManager Configuration.Instance.PropertyChanged += [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] (_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue); + + HumanName.Suffixes.Add("ret"); + HumanName.Titles.Add("professor"); } #endregion @@ -198,9 +203,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, NameListFormatter }, { TemplateTags.FirstAuthor, lb => lb.FirstAuthor }, - { TemplateTags.Narrator, lb => lb.NarratorNames }, + { TemplateTags.Narrator, lb => lb.Narrators, NameListFormatter }, { TemplateTags.FirstNarrator, lb => lb.FirstNarrator }, { TemplateTags.Series, lb => lb.SeriesName }, { TemplateTags.SeriesNumber, lb => lb.SeriesNumber }, @@ -247,6 +252,89 @@ namespace LibationFileManager #region Tag Formatters + /// <summary> Sort must have exactly one of the characters F, M, or L </summary> + [GeneratedRegex(@"[Ss]ort\(\s*?([FML])\s*?\)")] + private static partial Regex NamesSortRegex(); + /// <summary> Format must have at least one of the string {T}, {F}, {M}, {L}, or {S} </summary> + [GeneratedRegex(@"[Ff]ormat\((.*?(?:{[TFMLS]})+.*?)\)")] + private static partial Regex NamesFormatRegex(); + /// <summary> Separator can be anything </summary> + [GeneratedRegex(@"[Ss]eparator\((.*?)\)")] + private static partial Regex NamesSeparatorRegex(); + /// <summary> Max must have a 1 or 2-digit number </summary> + [GeneratedRegex(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)")] + private static partial Regex NamesMaxRegex(); + + private static string NameListFormatter(ITemplateTag templateTag, IEnumerable<string> names, string formatString) + { + var humanNames = names.Select(n => new HumanName(removeSuffix(n), Prefer.FirstOverPrefix)); + + var sortedNames = sort(humanNames, formatString); + var nameFormatString = format(formatString, defaultValue: "{T} {F} {M} {L} {S}"); + var separatorString = separator(formatString, defaultValue: ", "); + var maxNames = max(formatString, defaultValue: humanNames.Count()); + + var formattedNames = string.Join(separatorString, sortedNames.Take(maxNames).Select(n => formatName(n, nameFormatString))); + + while (formattedNames.Contains(" ")) + formattedNames = formattedNames.Replace(" ", " "); + + return formattedNames; + + static string removeSuffix(string namesString) + { + namesString = namesString.Replace('’', '\'').Replace(" - Ret.", ", Ret."); + int dashIndex = namesString.IndexOf(" - "); + return (dashIndex > 0 ? namesString[..dashIndex] : namesString).Trim(); + } + + static IEnumerable<HumanName> sort(IEnumerable<HumanName> humanNames, string formatString) + { + var sortMatch = NamesSortRegex().Match(formatString); + return + sortMatch.Success + ? sortMatch.Groups[1].Value == "F" ? humanNames.OrderBy(n => n.First) + : sortMatch.Groups[1].Value == "M" ? humanNames.OrderBy(n => n.Middle) + : sortMatch.Groups[1].Value == "L" ? humanNames.OrderBy(n => n.Last) + : humanNames + : humanNames; + } + + static string format(string formatString, string defaultValue) + { + var formatMatch = NamesFormatRegex().Match(formatString); + return formatMatch.Success ? formatMatch.Groups[1].Value : defaultValue; + } + + static string separator(string formatString, string defaultValue) + { + var separatorMatch = NamesSeparatorRegex().Match(formatString); + return separatorMatch.Success ? separatorMatch.Groups[1].Value : defaultValue; + } + + static int max(string formatString, int defaultValue) + { + var maxMatch = NamesMaxRegex().Match(formatString); + return maxMatch.Success && int.TryParse(maxMatch.Groups[1].Value, out var max) ? int.Max(1, max) : defaultValue; + } + + static string formatName(HumanName humanName, string nameFormatString) + { + //Single-word names parse as first names. Use it as last name. + var lastName = string.IsNullOrWhiteSpace(humanName.Last) ? humanName.First : humanName.Last; + + nameFormatString + = nameFormatString + .Replace("{T}", "{0}") + .Replace("{F}", "{1}") + .Replace("{M}", "{2}") + .Replace("{L}", "{3}") + .Replace("{S}", "{4}"); + + return string.Format(nameFormatString, humanName.Title, humanName.First, humanName.Middle, lastName, humanName.Suffix).Trim(); + } + } + 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/LibationSearchEngine/LuceneRegex.cs b/Source/LibationSearchEngine/LuceneRegex.cs index cc055098..4033ee48 100644 --- a/Source/LibationSearchEngine/LuceneRegex.cs +++ b/Source/LibationSearchEngine/LuceneRegex.cs @@ -5,7 +5,7 @@ using System.Text.RegularExpressions; namespace LibationSearchEngine { - internal static class LuceneRegex + internal static partial class LuceneRegex { #region pattern pieces // negative lookbehind: cannot be preceeded by an escaping \ @@ -38,28 +38,32 @@ namespace LibationSearchEngine private static string fieldPattern { get; } = NOT_ESCAPED + WORD_CAPTURE + FIELD_END; public static Regex FieldRegex { get; } = new Regex(fieldPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled); - // auto-pad numbers to 8 char.s. This will match int.s and dates (yyyyMMdd) - // positive look behind: beginning space { [ : - // positive look ahead: end space ] } - public static Regex NumbersRegex { get; } = new Regex(@"(?<=^|\s|\{|\[|:)(\d+\.?\d*)(?=$|\s|\]|\})", RegexOptions.Compiled); + /// <summary> + /// auto-pad numbers to 8 char.s. This will match int.s and dates (yyyyMMdd) + /// positive look behind: beginning space { [ : + /// positive look ahead: end space ] } + /// </summary> - /// <summary> - /// proper bools are single keywords which are turned into keyword:True - /// if bordered by colons or inside brackets, they are not stand-alone bool keywords - /// the negative lookbehind and lookahead patterns prevent bugs where a bool keyword is also a user-defined tag: - /// [israted] - /// parseTag => tags:israted - /// replaceBools => tags:israted:True - /// or - /// [israted] - /// replaceBools => israted:True - /// parseTag => [israted:True] - /// also don't want to apply :True where the value already exists: - /// israted:false => israted:false:True - /// - /// despite using parans, lookahead and lookbehind are zero-length assertions which do not capture. therefore the bool search keyword is still $1 since it's the first and only capture - /// </summary> - private static string boolPattern_parameterized { get; } + [GeneratedRegex(@"(?<=^|\s|\{|\[|:)(\d+\.?\d*)(?=$|\s|\]|\})", RegexOptions.Compiled)] + public static partial Regex NumbersRegex(); + + /// <summary> + /// proper bools are single keywords which are turned into keyword:True + /// if bordered by colons or inside brackets, they are not stand-alone bool keywords + /// the negative lookbehind and lookahead patterns prevent bugs where a bool keyword is also a user-defined tag: + /// [israted] + /// parseTag => tags:israted + /// replaceBools => tags:israted:True + /// or + /// [israted] + /// replaceBools => israted:True + /// parseTag => [israted:True] + /// also don't want to apply :True where the value already exists: + /// israted:false => israted:false:True + /// + /// despite using parans, lookahead and lookbehind are zero-length assertions which do not capture. therefore the bool search keyword is still $1 since it's the first and only capture + /// </summary> + private static string boolPattern_parameterized { get; } = @" ### IMPORTANT: 'ignore whitespace' is only partially honored in character sets ### - new lines are ok @@ -95,5 +99,5 @@ namespace LibationSearchEngine return regex; } - } + } } diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs index 3be589cb..a24266cd 100644 --- a/Source/LibationSearchEngine/SearchEngine.cs +++ b/Source/LibationSearchEngine/SearchEngine.cs @@ -402,7 +402,7 @@ namespace LibationSearchEngine private static string padNumbers(string searchString) { var matches = LuceneRegex - .NumbersRegex + .NumbersRegex() .Matches(searchString) .Cast<Match>() .OrderByDescending(m => m.Index); @@ -410,7 +410,7 @@ namespace LibationSearchEngine foreach (var m in matches) { var replaceString = double.Parse(m.ToString()).ToLuceneString(); - searchString = LuceneRegex.NumbersRegex.Replace(searchString, replaceString, 1, m.Index); + searchString = LuceneRegex.NumbersRegex().Replace(searchString, replaceString, 1, m.Index); } return searchString; diff --git a/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.Designer.cs b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.Designer.cs new file mode 100644 index 00000000..2bb3f854 --- /dev/null +++ b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.Designer.cs @@ -0,0 +1,107 @@ +namespace LibationWinForms.Dialogs +{ + partial class LocateAudiobooksDialog + { + /// <summary> + /// Required designer variable. + /// </summary> + private System.ComponentModel.IContainer components = null; + + /// <summary> + /// Clean up any resources being used. + /// </summary> + /// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param> + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// <summary> + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// </summary> + private void InitializeComponent() + { + this.label1 = new System.Windows.Forms.Label(); + this.foundAudiobooksLV = new System.Windows.Forms.ListView(); + this.columnHeader1 = new System.Windows.Forms.ColumnHeader(); + this.columnHeader2 = new System.Windows.Forms.ColumnHeader(); + this.booksFoundLbl = new System.Windows.Forms.Label(); + this.SuspendLayout(); + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(12, 9); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(108, 15); + this.label1.TabIndex = 1; + this.label1.Text = "Found Audiobooks"; + // + // foundAudiobooksLV + // + this.foundAudiobooksLV.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.foundAudiobooksLV.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { + this.columnHeader1, + this.columnHeader2}); + this.foundAudiobooksLV.FullRowSelect = true; + this.foundAudiobooksLV.Location = new System.Drawing.Point(12, 33); + this.foundAudiobooksLV.Name = "foundAudiobooksLV"; + this.foundAudiobooksLV.Size = new System.Drawing.Size(321, 261); + this.foundAudiobooksLV.TabIndex = 2; + this.foundAudiobooksLV.UseCompatibleStateImageBehavior = false; + this.foundAudiobooksLV.View = System.Windows.Forms.View.Details; + // + // columnHeader1 + // + this.columnHeader1.Text = "Book ID"; + this.columnHeader1.Width = 85; + // + // columnHeader2 + // + this.columnHeader2.Text = "Title"; + // + // booksFoundLbl + // + this.booksFoundLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); + this.booksFoundLbl.AutoSize = true; + this.booksFoundLbl.Location = new System.Drawing.Point(253, 9); + this.booksFoundLbl.Name = "booksFoundLbl"; + this.booksFoundLbl.Size = new System.Drawing.Size(80, 15); + this.booksFoundLbl.TabIndex = 3; + this.booksFoundLbl.Text = "IDs Found: {0}"; + this.booksFoundLbl.TextAlign = System.Drawing.ContentAlignment.TopRight; + // + // LocateAudiobooksDialog + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(345, 306); + this.Controls.Add(this.booksFoundLbl); + this.Controls.Add(this.foundAudiobooksLV); + this.Controls.Add(this.label1); + this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow; + this.Name = "LocateAudiobooksDialog"; + this.Text = "Locate Audiobooks"; + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Label label1; + private System.Windows.Forms.ListView foundAudiobooksLV; + private System.Windows.Forms.ColumnHeader columnHeader1; + private System.Windows.Forms.ColumnHeader columnHeader2; + private System.Windows.Forms.Label booksFoundLbl; + } +} \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs new file mode 100644 index 00000000..abaca53d --- /dev/null +++ b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.cs @@ -0,0 +1,98 @@ +using ApplicationServices; +using DataLayer; +using LibationFileManager; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace LibationWinForms.Dialogs +{ + public partial class LocateAudiobooksDialog : Form + { + private event EventHandler<FilePathCache.CacheEntry> FileFound; + private readonly CancellationTokenSource tokenSource = new(); + private readonly List<string> foundAsins = new(); + private readonly string labelFormatText; + public LocateAudiobooksDialog() + { + InitializeComponent(); + + labelFormatText = booksFoundLbl.Text; + setFoundBookCount(0); + + this.SetLibationIcon(); + this.RestoreSizeAndLocation(Configuration.Instance); + + Shown += LocateAudiobooks_Shown; + FileFound += LocateAudiobooks_FileFound; + FormClosing += LocateAudiobooks_FormClosing; + } + + private void setFoundBookCount(int count) + => booksFoundLbl.Text = string.Format(labelFormatText, count); + + private void LocateAudiobooks_FileFound(object sender, FilePathCache.CacheEntry e) + { + foundAudiobooksLV.Items + .Add(new ListViewItem(new string[] { $"[{e.Id}]", Path.GetFileName(e.Path) })) + .EnsureVisible(); + + foundAudiobooksLV.AutoResizeColumn(1, ColumnHeaderAutoResizeStyle.ColumnContent); + + if (!foundAsins.Any(asin => asin == e.Id)) + { + foundAsins.Add(e.Id); + setFoundBookCount(foundAsins.Count); + } + } + + private void LocateAudiobooks_FormClosing(object sender, FormClosingEventArgs e) + { + tokenSource.Cancel(); + this.SaveSizeAndLocation(Configuration.Instance); + } + + private async void LocateAudiobooks_Shown(object sender, EventArgs e) + { + var fbd = new FolderBrowserDialog + { + Description = "Select the folder to search for audiobooks", + UseDescriptionForTitle = true, + InitialDirectory = Configuration.Instance.Books + }; + + if (fbd.ShowDialog() != DialogResult.OK || !Directory.Exists(fbd.SelectedPath)) + { + Close(); + return; + } + + using var context = DbContexts.GetContext(); + + await foreach (var book in AudioFileStorage.FindAudiobooksAsync(fbd.SelectedPath, tokenSource.Token)) + { + try + { + FilePathCache.Insert(book); + + var lb = context.GetLibraryBook_Flat_NoTracking(book.Id); + if (lb.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated) + await Task.Run(() => lb.UpdateBookStatus(LiberatedStatus.Liberated)); + + this.Invoke(FileFound, this, book); + } + catch(Exception ex) + { + Serilog.Log.Error(ex, "Error adding found audiobook file to Libation. {@audioFile}", book); + } + } + + MessageBox.Show(this, $"Libation has found {foundAsins.Count} unique audiobooks and added them to its database. ", $"Found {foundAsins.Count} Audiobooks"); + Close(); + } + } +} diff --git a/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.resx b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.resx new file mode 100644 index 00000000..f298a7be --- /dev/null +++ b/Source/LibationWinForms/Dialogs/LocateAudiobooksDialog.resx @@ -0,0 +1,60 @@ +<root> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> +</root> \ No newline at end of file diff --git a/Source/LibationWinForms/Form1.Designer.cs b/Source/LibationWinForms/Form1.Designer.cs index 335d47f4..56e99a98 100644 --- a/Source/LibationWinForms/Form1.Designer.cs +++ b/Source/LibationWinForms/Form1.Designer.cs @@ -60,8 +60,10 @@ this.setBookDownloadedManualToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.setPdfDownloadedManualToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.setDownloadedAutoToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); + this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.locateAudiobooksToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.accountsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.basicSettingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); @@ -149,7 +151,9 @@ this.scanLibraryToolStripMenuItem, this.scanLibraryOfAllAccountsToolStripMenuItem, this.scanLibraryOfSomeAccountsToolStripMenuItem, - this.removeLibraryBooksToolStripMenuItem}); + this.removeLibraryBooksToolStripMenuItem, + this.toolStripSeparator3, + this.locateAudiobooksToolStripMenuItem}); this.importToolStripMenuItem.Name = "importToolStripMenuItem"; this.importToolStripMenuItem.Size = new System.Drawing.Size(55, 20); this.importToolStripMenuItem.Text = "&Import"; @@ -560,10 +564,22 @@ this.processBookQueue1.Name = "processBookQueue1"; this.processBookQueue1.Size = new System.Drawing.Size(430, 640); this.processBookQueue1.TabIndex = 0; - // - // Form1 - // - this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + // + // locateAudiobooksToolStripMenuItem + // + this.locateAudiobooksToolStripMenuItem.Name = "locateAudiobooksToolStripMenuItem"; + this.locateAudiobooksToolStripMenuItem.Size = new System.Drawing.Size(247, 22); + this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks"; + this.locateAudiobooksToolStripMenuItem.Click += new System.EventHandler(this.locateAudiobooksToolStripMenuItem_Click); + // + // toolStripSeparator3 + // + this.toolStripSeparator3.Name = "toolStripSeparator3"; + this.toolStripSeparator3.Size = new System.Drawing.Size(244, 6); + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(1463, 640); this.Controls.Add(this.splitContainer1); @@ -630,6 +646,8 @@ private System.Windows.Forms.ToolStripMenuItem setBookDownloadedManualToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem setDownloadedAutoToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator3; + private System.Windows.Forms.ToolStripMenuItem locateAudiobooksToolStripMenuItem; private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu; private System.Windows.Forms.SplitContainer splitContainer1; private LibationWinForms.ProcessQueue.ProcessQueueControl processBookQueue1; diff --git a/Source/LibationWinForms/Form1.ScanManual.cs b/Source/LibationWinForms/Form1.ScanManual.cs index b90c8826..b238cbd6 100644 --- a/Source/LibationWinForms/Form1.ScanManual.cs +++ b/Source/LibationWinForms/Form1.ScanManual.cs @@ -89,5 +89,10 @@ namespace LibationWinForms ex); } } + + private void locateAudiobooksToolStripMenuItem_Click(object sender, EventArgs e) + { + new LocateAudiobooksDialog().ShowDialog(); + } } } diff --git a/Source/LibationWinForms/Form1.cs b/Source/LibationWinForms/Form1.cs index 8b7c6767..3e450559 100644 --- a/Source/LibationWinForms/Form1.cs +++ b/Source/LibationWinForms/Form1.cs @@ -7,6 +7,7 @@ using ApplicationServices; using Dinah.Core; using Dinah.Core.Threading; using LibationFileManager; +using LibationWinForms.Dialogs; namespace LibationWinForms { diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 394c7ecc..691db140 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -238,6 +238,89 @@ namespace TemplatesTests .Should().Be(expected); } + [TestMethod] + [DataRow("Bruce Bueno de Mesquita", "Title=, First=Bruce, Middle=Bueno Last=de Mesquita, Suffix=")] + [DataRow("Ramon de Ocampo", "Title=, First=Ramon, Middle= Last=de Ocampo, Suffix=")] + [DataRow("Ramon De Ocampo", "Title=, First=Ramon, Middle= Last=De Ocampo, Suffix=")] + [DataRow("Jennifer Van Dyck", "Title=, First=Jennifer, Middle= Last=Van Dyck, Suffix=")] + [DataRow("Carla Naumburg PhD", "Title=, First=Carla, Middle= Last=Naumburg, Suffix=PhD")] + [DataRow("Doug Stanhope and Friends", "Title=, First=Doug, Middle= Last=Stanhope and Friends, Suffix=")] + [DataRow("Tamara Lovatt-Smith", "Title=, First=Tamara, Middle= Last=Lovatt-Smith, Suffix=")] + [DataRow("Common", "Title=, First=Common, Middle= Last=Common, Suffix=")] + [DataRow("Doug Tisdale Jr.", "Title=, First=Doug, Middle= Last=Tisdale, Suffix=Jr")] + [DataRow("Robert S. Mueller III", "Title=, First=Robert, Middle=S. Last=Mueller, Suffix=III")] + [DataRow("Frank T Vertosick Jr. MD", "Title=, First=Frank, Middle=T Last=Vertosick, Suffix=Jr. MD")] + [DataRow("The Arabian Nights", "Title=, First=The Arabian, Middle= Last=Nights, Suffix=")] + [DataRow("The Great Courses", "Title=, First=The Great, Middle= Last=Courses, Suffix=")] + [DataRow("The Laurie Berkner Band", "Title=, First=The Laurie, Middle=Berkner Last=Band, Suffix=")] + [DataRow("Committee on Foreign Affairs", "Title=, First=Committee, Middle=on Last=Foreign Affairs, Suffix=")] + [DataRow("House Permanent Select Committee on Intelligence", "Title=, First=House, Middle=Permanent Select Committee on Last=Intelligence, Suffix=")] + [DataRow("Professor David K. Johnson PhD University of Oklahoma", "Title=Professor, First=David, Middle=K. Johnson PhD Last=University of Oklahoma, Suffix=")] + [DataRow("Festival of the Spoken Nerd", "Title=, First=Festival of the Spoken, Middle= Last=Nerd, Suffix=")] + [DataRow("Audible Original", "Title=, First=Audible, Middle= Last=Original, Suffix=")] + [DataRow("Audible Originals", "Title=, First=Audible, Middle= Last=Originals, Suffix=")] + [DataRow("Patrick O'Brian", "Title=, First=Patrick, Middle= Last=O'Brian, Suffix=")] + [DataRow("Patrick O’Connell", "Title=, First=Patrick, Middle= Last=O'Connell, Suffix=")] + [DataRow("L.E. Modesitt", "Title=, First=L.E., Middle= Last=Modesitt, Suffix=")] + [DataRow("L. E. Modesitt Jr.", "Title=, First=L., Middle=E. Last=Modesitt, Suffix=Jr")] + [DataRow("LE Modesitt, Jr.", "Title=, First=LE, Middle= Last=Modesitt, Suffix=Jr")] + [DataRow("Marine Le Pen", "Title=, First=Marine, Middle= Last=Le Pen, Suffix=")] + [DataRow("L. Sprague de Camp", "Title=, First=L., Middle=Sprague Last=de Camp, Suffix=")] + [DataRow("Lt. Col. - Ret. Douglas L. Bland", "Title=, First=Ret., Middle=Douglas L. Bland Last=Lt. Col., Suffix=")] + [DataRow("Col. Lee Ellis - Ret. - foreword", "Title=Col., First=Lee, Middle= Last=Ellis, Suffix=Ret")] + public void NameFormat_unusual(string author, string expected) + { + var bookDto = GetLibraryBook(); + bookDto.Authors = new List<string> { author }; + Templates.TryGetTemplate<Templates.FileTemplate>("<author[format(Title={T}, First={F}, Middle={M} Last={L}, Suffix={S})]>", out var fileTemplate).Should().BeTrue(); + fileTemplate + .GetFilename(bookDto, "", "", Replacements) + .PathWithoutPrefix + .Should().Be(expected); + } + + [TestMethod] + [DataRow("<author>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")] + [DataRow("<author[]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")] + [DataRow("<author[sort(F)]>", "Charles E. Gannon, Christopher John Fetherolf, Jill Conner Browne, Jon Bon Jovi, Lucy Maud Montgomery, Paul Van Doren")] + [DataRow("<author[sort(L)]>", "Jon Bon Jovi, Jill Conner Browne, Christopher John Fetherolf, Charles E. Gannon, Lucy Maud Montgomery, Paul Van Doren")] + [DataRow("<author[sort(M)]>", "Jon Bon Jovi, Paul Van Doren, Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery")] + [DataRow("<author[sort(f)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")] + [DataRow("<author[sort(m)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")] + [DataRow("<author[sort(l)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")] + [DataRow("<author [ max( 1 ) ]>", "Jill Conner Browne")] + [DataRow("<author[max(2)]>", "Jill Conner Browne, Charles E. Gannon")] + [DataRow("<author[max(3)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf")] + [DataRow("<author[format({L}, {F})]>", "Browne, Jill, Gannon, Charles, Fetherolf, Christopher, Montgomery, Lucy, Bon Jovi, Jon, Van Doren, Paul")] + [DataRow("<author[format({f}, {l})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")] + [DataRow("<author[format(First={F}, Last={L})]>", "First=Jill, Last=Browne, First=Charles, Last=Gannon, First=Christopher, Last=Fetherolf, First=Lucy, Last=Montgomery, First=Jon, Last=Bon Jovi, First=Paul, Last=Van Doren")] + [DataRow("<author[format({L}, {F}) separator( - ) max(3)]>", "Browne, Jill - Gannon, Charles - Fetherolf, Christopher")] + [DataRow("<author[sort(F) max(2) separator(; ) format({F})]>", "Charles; Christopher")] + [DataRow("<author[sort(L) max(2) separator(; ) format({L})]>", "Bon Jovi; Browne")] + //Jon Bon Jovi and Paul Van Doren don't have middle names, so they are sorted to the top. + //Since only the middle names of the first 2 names are to be displayed, the name string is empty. + [DataRow("<author[sort(M) max(2) separator(; ) format({M})]>", ";")] + public void NameFormat_formatters(string template, string expected) + { + var bookDto = GetLibraryBook(); + bookDto.Authors = new List<string> + { + "Jill Conner Browne", + "Charles E. Gannon", + "Christopher John Fetherolf", + "Lucy Maud Montgomery", + "Jon Bon Jovi", + "Paul Van Doren" + }; + + 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)]