diff --git a/Source/LibationAvalonia/Controls/DataGridContextMenus.cs b/Source/LibationAvalonia/Controls/DataGridContextMenus.cs index 60e939cf..d4db742b 100644 --- a/Source/LibationAvalonia/Controls/DataGridContextMenus.cs +++ b/Source/LibationAvalonia/Controls/DataGridContextMenus.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using LibationUiBase.GridView; using System; +using System.Linq; using System.Reflection; namespace LibationAvalonia.Controls @@ -12,11 +13,13 @@ namespace LibationAvalonia.Controls private static readonly ContextMenu ContextMenu = new(); private static readonly AvaloniaList MenuItems = new(); private static readonly PropertyInfo OwningColumnProperty; + private static readonly PropertyInfo OwningGridProperty; static DataGridContextMenus() { ContextMenu.ItemsSource = MenuItems; OwningColumnProperty = typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic); + OwningGridProperty = typeof(DataGridColumn).GetProperty("OwningGrid", BindingFlags.Instance | BindingFlags.NonPublic); } public static void AttachContextMenu(this DataGridCell cell) @@ -30,19 +33,35 @@ namespace LibationAvalonia.Controls private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e) { - if (sender is DataGridCell cell && cell.DataContext is IGridEntry entry) + if (sender is DataGridCell cell && + cell.DataContext is IGridEntry clickedEntry && + OwningColumnProperty.GetValue(cell) is DataGridColumn column && + OwningGridProperty.GetValue(column) is DataGrid grid) { + var allSelected = grid.SelectedItems.OfType().ToArray(); + var clickedIndex = Array.IndexOf(allSelected, clickedEntry); + if (clickedIndex == -1) + { + //User didn't right-click on a selected cell + grid.SelectedItem = clickedEntry; + allSelected = [clickedEntry]; + } + else if (clickedIndex > 0) + { + //Ensure the clicked entry is first in the list + (allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]); + } + var args = new DataGridCellContextMenuStripNeededEventArgs { - Column = OwningColumnProperty.GetValue(cell) as DataGridColumn, - GridEntry = entry, + Column = column, + Grid = grid, + GridEntries = allSelected, ContextMenu = ContextMenu }; args.ContextMenuItems.Clear(); - CellContextMenuStripNeeded?.Invoke(sender, args); - e.Handled = args.ContextMenuItems.Count == 0; } else @@ -61,10 +80,37 @@ namespace LibationAvalonia.Controls private static string GetCellValue(DataGridColumn column, object item) => GetCellValueMethod.Invoke(column, new object[] { item, column.ClipboardContentBinding })?.ToString() ?? ""; - public string CellClipboardContents => GetCellValue(Column, GridEntry); - public DataGridColumn Column { get; init; } - public IGridEntry GridEntry { get; init; } - public ContextMenu ContextMenu { get; init; } + public string CellClipboardContents => GetCellValue(Column, GridEntries[0]); + public string GetRowClipboardContents() + { + if (GridEntries is null || GridEntries.Length == 0) + return string.Empty; + else if (GridEntries.Length == 1) + return HeaderNames + Environment.NewLine + GetRowClipboardContents(GridEntries[0]); + else + return string.Join(Environment.NewLine, GridEntries.Select(GetRowClipboardContents).Prepend(HeaderNames)); + } + + private string HeaderNames + => string.Join("\t", + Grid.Columns + .Where(c => c.IsVisible) + .OrderBy(c => c.DisplayIndex) + .Select(c => RemoveLineBreaks(c.Header.ToString()))); + + private static string RemoveLineBreaks(string text) + => text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' '); + + private string GetRowClipboardContents(IGridEntry gridEntry) + { + var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray(); + return string.Join("\t", contents); + } + + public required DataGrid Grid { get; init; } + public required DataGridColumn Column { get; init; } + public required IGridEntry[] GridEntries { get; init; } + public required ContextMenu ContextMenu { get; init; } public AvaloniaList ContextMenuItems => ContextMenu.ItemsSource as AvaloniaList; } diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs index 57ffa535..69dc697c 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs @@ -33,37 +33,54 @@ namespace LibationAvalonia.ViewModels setQueueCollapseState(collapseState); } - public async void LiberateClicked(LibraryBook libraryBook) + public async void LiberateClicked(LibraryBook[] libraryBooks) { try { - if (libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + if (libraryBooks.Length == 1) { - Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", libraryBook); - setQueueCollapseState(false); - ProcessQueue.AddDownloadDecrypt(libraryBook); - } - else if (libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - { - Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", libraryBook); - setQueueCollapseState(false); - ProcessQueue.AddDownloadPdf(libraryBook); - } - else if (libraryBook.Book.Audio_Exists()) - { - // liberated: open explorer to file - var filePath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId); - - if (!Go.To.File(filePath?.ShortPathName)) + var item = libraryBooks[0]; + if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) { - var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; - await MessageBox.Show($"File not found" + suffix); + Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item); + setQueueCollapseState(false); + ProcessQueue.AddDownloadDecrypt(item); + } + else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + { + Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); + setQueueCollapseState(false); + ProcessQueue.AddDownloadPdf(item); + } + else if (item.Book.Audio_Exists()) + { + // liberated: open explorer to file + var filePath = AudibleFileStorage.Audio.GetPath(item.Book.AudibleProductId); + + if (!Go.To.File(filePath?.ShortPathName)) + { + var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; + await MessageBox.Show($"File not found" + suffix); + } + } + } + else + { + var toLiberate + = libraryBooks + .Where(x => x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + .ToArray(); + + if (toLiberate.Length > 0) + { + setQueueCollapseState(false); + ProcessQueue.AddDownloadDecrypt(toLiberate); } } } catch (Exception ex) { - Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBook); + Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); } } @@ -83,20 +100,21 @@ namespace LibationAvalonia.ViewModels } } - public void ConvertToMp3Clicked(LibraryBook libraryBook) + public void ConvertToMp3Clicked(LibraryBook[] libraryBooks) { try { - if (libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated) + var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray(); + if (preLiberated.Length > 0) { - Serilog.Log.Logger.Information("Begin convert to mp3 {libraryBook}", libraryBook); + Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length); setQueueCollapseState(false); - ProcessQueue.AddConvertMp3(libraryBook); + ProcessQueue.AddConvertMp3(preLiberated); } } catch (Exception ex) { - Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBook); + Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); } } diff --git a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index fcc13a9e..e318977c 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -91,19 +91,19 @@ namespace LibationAvalonia.ViewModels public decimal SpeedLimitIncrement { get; private set; } - private async void Queue_CompletedCountChanged(object? sender, int e) + private void Queue_CompletedCountChanged(object? sender, int e) { int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail); int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success); ErrorCount = errCount; CompletedCount = completeCount; - await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress))); + Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress))); } - private async void Queue_QueuededCountChanged(object? sender, int cueCount) + private void Queue_QueuededCountChanged(object? sender, int cueCount) { QueuedCount = cueCount; - await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress))); + Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress))); } public void WriteLine(string text) diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 1f47c5ef..950bfc5e 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -134,9 +134,9 @@ namespace LibationAvalonia.Views Task.Run(() => vm.ProductsDisplay.BindToGridAsync(initialLibrary))); } - public void ProductsDisplay_LiberateClicked(object _, LibraryBook libraryBook) => ViewModel.LiberateClicked(libraryBook); + public void ProductsDisplay_LiberateClicked(object _, LibraryBook[] libraryBook) => ViewModel.LiberateClicked(libraryBook); public void ProductsDisplay_LiberateSeriesClicked(object _, ISeriesEntry series) => ViewModel.LiberateSeriesClicked(series); - public void ProductsDisplay_ConvertToMp3Clicked(object _, LibraryBook libraryBook) => ViewModel.ConvertToMp3Clicked(libraryBook); + public void ProductsDisplay_ConvertToMp3Clicked(object _, LibraryBook[] libraryBook) => ViewModel.ConvertToMp3Clicked(libraryBook); BookDetailsDialog bookDetailsForm; public void ProductsDisplay_TagsButtonClicked(object _, LibraryBook libraryBook) diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index fd18d16b..707399a0 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -24,9 +24,9 @@ namespace LibationAvalonia.Views { public partial class ProductsDisplay : UserControl { - public event EventHandler? LiberateClicked; + public event EventHandler? LiberateClicked; public event EventHandler? LiberateSeriesClicked; - public event EventHandler? ConvertToMp3Clicked; + public event EventHandler? ConvertToMp3Clicked; public event EventHandler? TagsButtonClicked; private ProductsDisplayViewModel? _viewModel => DataContext as ProductsDisplayViewModel; @@ -191,29 +191,41 @@ namespace LibationAvalonia.Views public void ProductsGrid_CellContextMenuStripNeeded(object? sender, DataGridCellContextMenuStripNeededEventArgs args) { - var entry = args.GridEntry; - var ctx = new GridContextMenu(entry, '_'); + var entries = args.GridEntries; + var ctx = new GridContextMenu(entries, '_'); - if (args.Column.SortMemberPath is not "Liberate" and not "Cover" - && App.MainWindow?.Clipboard is IClipboard clipboard) + if (App.MainWindow?.Clipboard is IClipboard clipboard) { + //Avalonia's DataGrid can't select individual cells, so add separate + //options for copying single cell's contents and who row contents. + if (entries.Length == 1 && args.Column.SortMemberPath is not "Liberate" and not "Cover") + { + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.CopyCellText, + Command = ReactiveCommand.CreateFromTask(() => clipboard?.SetTextAsync(args.CellClipboardContents) ?? Task.CompletedTask) + }); + } + args.ContextMenuItems.Add(new MenuItem { - Header = ctx.CopyCellText, - Command = ReactiveCommand.CreateFromTask(() => clipboard?.SetTextAsync(args.CellClipboardContents) ?? Task.CompletedTask) + Header = "_Copy Row Contents", + Command = ReactiveCommand.CreateFromTask(() => clipboard?.SetTextAsync(args.GetRowClipboardContents()) ?? Task.CompletedTask) }); + args.ContextMenuItems.Add(new Separator()); } + - #region Liberate all Episodes + #region Liberate all Episodes (Single series only) - if (entry.Liberate.IsSeries) + if (entries.Length == 1 && entries[0] is ISeriesEntry seriesEntry) { args.ContextMenuItems.Add(new MenuItem() { Header = ctx.LiberateEpisodesText, IsEnabled = ctx.LiberateEpisodesEnabled, - Command = ReactiveCommand.Create(() => LiberateSeriesClicked?.Invoke(this, (ISeriesEntry)entry)) + Command = ReactiveCommand.Create(() => LiberateSeriesClicked?.Invoke(this, seriesEntry)) }); } @@ -238,20 +250,10 @@ namespace LibationAvalonia.Views }); #endregion - #region Remove from library + #region Locate file (Single book only) - args.ContextMenuItems.Add(new MenuItem + if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry) { - Header = ctx.RemoveText, - Command = ReactiveCommand.CreateFromTask(ctx.RemoveAsync) - }); - - #endregion - - if (!entry.Liberate.IsSeries) - { - #region Locate file - args.ContextMenuItems.Add(new MenuItem { Header = ctx.LocateFileText, @@ -285,21 +287,44 @@ namespace LibationAvalonia.Views } }) }); + } - #endregion - #region Convert to Mp3 + #endregion + #region Remove from library + + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.RemoveText, + Command = ReactiveCommand.CreateFromTask(ctx.RemoveAsync) + }); + + #endregion + #region Liberate All (multiple books only) + if (entries.OfType().Count() > 1) + { + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.DownloadSelectedText, + Command = ReactiveCommand.Create(() => LiberateClicked?.Invoke(this, ctx.LibraryBookEntries.Select(e => e.LibraryBook).ToArray())) + }); + } + + #endregion + #region Convert to Mp3 + + if (ctx.LibraryBookEntries.Length > 0) + { args.ContextMenuItems.Add(new MenuItem { Header = ctx.ConvertToMp3Text, IsEnabled = ctx.ConvertToMp3Enabled, - Command = ReactiveCommand.Create(() => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook)) + Command = ReactiveCommand.Create(() => ConvertToMp3Clicked?.Invoke(this, ctx.LibraryBookEntries.Select(e => e.LibraryBook).ToArray())) }); - - #endregion } - #region Force Re-Download - if (!entry.Liberate.IsSeries) + #endregion + #region Force Re-Download (Single book only) + if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry4) { args.ContextMenuItems.Add(new MenuItem() { @@ -307,17 +332,23 @@ namespace LibationAvalonia.Views IsEnabled = ctx.ReDownloadEnabled, Command = ReactiveCommand.Create(() => { - //No need to persist this change. It only needs to last long for the file to start downloading - entry.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; - LiberateClicked?.Invoke(this, entry.LibraryBook); + //No need to persist these changes. They only needs to last long for the files to start downloading + entry4.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; + if (entry4.Book.HasPdf()) + entry4.Book.UserDefinedItem.SetPdfStatus(LiberatedStatus.NotLiberated); + LiberateClicked?.Invoke(this, [entry4.LibraryBook]); }) }); } #endregion + if (entries.Length > 1) + return; + args.ContextMenuItems.Add(new Separator()); - #region Edit Templates + #region Edit Templates (Single book only) + async Task editTemplate(LibraryBook libraryBook, string existingTemplate, Action setNewTemplate) where T : Templates, LibationFileManager.ITemplate, new() { @@ -329,7 +360,7 @@ namespace LibationAvalonia.Views } } - if (!entry.Liberate.IsSeries) + if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry2) { args.ContextMenuItems.Add(new MenuItem { @@ -339,17 +370,17 @@ namespace LibationAvalonia.Views new MenuItem { Header = ctx.FolderTemplateText, - Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t)) + Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry2.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t)) }, new MenuItem { Header = ctx.FileTemplateText, - Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t)) + Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry2.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t)) }, new MenuItem { Header = ctx.MultipartTemplateText, - Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t)) + Command = ReactiveCommand.CreateFromTask(() => editTemplate(entry2.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t)) } } }); @@ -357,27 +388,26 @@ namespace LibationAvalonia.Views } #endregion + #region View Bookmarks/Clips (Single book only) - #region View Bookmarks/Clips - - if (!entry.Liberate.IsSeries && VisualRoot is Window window) + if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry3 && VisualRoot is Window window) { args.ContextMenuItems.Add(new MenuItem { Header = ctx.ViewBookmarksText, - Command = ReactiveCommand.CreateFromTask(() => new BookRecordsDialog(entry.LibraryBook).ShowDialog(window)) + Command = ReactiveCommand.CreateFromTask(() => new BookRecordsDialog(entry3.LibraryBook).ShowDialog(window)) }); } #endregion - #region View All Series + #region View All Series (Single book only) - if (entry.Book.SeriesLink.Any()) + if (entries.Length == 1 && entries[0].Book.SeriesLink.Any()) { args.ContextMenuItems.Add(new MenuItem { Header = ctx.ViewSeriesText, - Command = ReactiveCommand.Create(() => new SeriesViewDialog(entry.LibraryBook).Show()) + Command = ReactiveCommand.Create(() => new SeriesViewDialog(entries[0].LibraryBook).Show()) }); } @@ -515,7 +545,7 @@ namespace LibationAvalonia.Views } else if (button.DataContext is ILibraryBookEntry lbEntry) { - LiberateClicked?.Invoke(this, lbEntry.LibraryBook); + LiberateClicked?.Invoke(this, [lbEntry.LibraryBook]); } } diff --git a/Source/LibationUiBase/GridView/GridContextMenu.cs b/Source/LibationUiBase/GridView/GridContextMenu.cs index 9e9c78ae..c35f0403 100644 --- a/Source/LibationUiBase/GridView/GridContextMenu.cs +++ b/Source/LibationUiBase/GridView/GridContextMenu.cs @@ -5,7 +5,6 @@ using LibationFileManager; using System; using System.Linq; using System.Threading.Tasks; -using System.Windows.Input; namespace LibationUiBase.GridView; @@ -17,66 +16,68 @@ public class GridContextMenu public string SetNotDownloadedText => $"Set Download status to '{Accelerator}Not Downloaded'"; public string RemoveText => $"{Accelerator}Remove from library"; public string LocateFileText => $"{Accelerator}Locate file..."; - public string LocateFileDialogTitle => $"Locate the audio file for '{GridEntry.Book.TitleWithSubtitle}'"; + public string LocateFileDialogTitle => $"Locate the audio file for '{GridEntries[0].Book.TitleWithSubtitle}'"; public string LocateFileErrorMessage => "Error saving book's location"; public string ConvertToMp3Text => $"{Accelerator}Convert to Mp3"; public string ReDownloadText => "Re-download this audiobook"; + public string DownloadSelectedText => "Download selected audiobooks"; public string EditTemplatesText => "Edit Templates"; public string FolderTemplateText => "Folder Template"; public string FileTemplateText => "File Template"; public string MultipartTemplateText => "Multipart File Template"; public string ViewBookmarksText => "View _Bookmarks/Clips"; - public string ViewSeriesText => GridEntry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series"; + public string ViewSeriesText => GridEntries[0].Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series"; - public bool LiberateEpisodesEnabled => GridEntry is ISeriesEntry sEntry && sEntry.Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload); - public bool SetDownloadedEnabled => GridEntry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || GridEntry.Liberate.IsSeries; - public bool SetNotDownloadedEnabled => GridEntry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || GridEntry.Liberate.IsSeries; - public bool ConvertToMp3Enabled => GridEntry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated; - public bool ReDownloadEnabled => GridEntry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated; + public bool LiberateEpisodesEnabled => GridEntries.OfType().Any(sEntry => sEntry.Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)); + public bool SetDownloadedEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || ge.Liberate.IsSeries); + public bool SetNotDownloadedEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || ge.Liberate.IsSeries); + public bool ConvertToMp3Enabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated); + public bool ReDownloadEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated); - public IGridEntry GridEntry { get; } + private IGridEntry[] GridEntries { get; } + public ILibraryBookEntry[] LibraryBookEntries { get; } public char Accelerator { get; } - public GridContextMenu(IGridEntry gridEntry, char accelerator) + public GridContextMenu(IGridEntry[] gridEntries, char accelerator) { - GridEntry = gridEntry; + ArgumentNullException.ThrowIfNull(gridEntries, nameof(gridEntries)); + ArgumentOutOfRangeException.ThrowIfZero(gridEntries.Length, $"{nameof(gridEntries)}.{nameof(gridEntries.Length)}"); + + GridEntries = gridEntries; Accelerator = accelerator; + LibraryBookEntries + = GridEntries + .OfType() + .SelectMany(s => s.Children) + .Concat(GridEntries.OfType()) + .ToArray(); } public void SetDownloaded() { - if (GridEntry is ISeriesEntry series) - { - series.Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.Liberated); - } - else - { - GridEntry.LibraryBook.UpdateBookStatus(LiberatedStatus.Liberated); - } + LibraryBookEntries.Select(e => e.LibraryBook) + .UpdateUserDefinedItem(udi => + { + udi.BookStatus = LiberatedStatus.Liberated; + if (udi.Book.HasPdf()) + udi.SetPdfStatus(LiberatedStatus.Liberated); + }); } public void SetNotDownloaded() { - if (GridEntry is ISeriesEntry series) - { - series.Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.NotLiberated); - } - else - { - GridEntry.LibraryBook.UpdateBookStatus(LiberatedStatus.NotLiberated); - } + LibraryBookEntries.Select(e => e.LibraryBook) + .UpdateUserDefinedItem(udi => + { + udi.BookStatus = LiberatedStatus.NotLiberated; + if (udi.Book.HasPdf()) + udi.SetPdfStatus(LiberatedStatus.NotLiberated); + }); } public async Task RemoveAsync() { - if (GridEntry is ISeriesEntry series) - { - await series.Children.Select(c => c.LibraryBook).RemoveBooksAsync(); - } - else - { - await Task.Run(GridEntry.LibraryBook.RemoveBook); - } + await LibraryBookEntries.Select(e => e.LibraryBook).RemoveBooksAsync(); } public ITemplateEditor CreateTemplateEditor(LibraryBook libraryBook, string existingTemplate) diff --git a/Source/LibationWinForms/Form1.Designer.cs b/Source/LibationWinForms/Form1.Designer.cs index 0c147389..19b75a3f 100644 --- a/Source/LibationWinForms/Form1.Designer.cs +++ b/Source/LibationWinForms/Form1.Designer.cs @@ -537,9 +537,9 @@ this.productsDisplay.TabIndex = 9; this.productsDisplay.VisibleCountChanged += new System.EventHandler(this.productsDisplay_VisibleCountChanged); this.productsDisplay.RemovableCountChanged += new System.EventHandler(this.productsDisplay_RemovableCountChanged); - this.productsDisplay.LiberateClicked += new System.EventHandler(this.ProductsDisplay_LiberateClicked); + this.productsDisplay.LiberateClicked += ProductsDisplay_LiberateClicked; this.productsDisplay.LiberateSeriesClicked += new System.EventHandler(this.ProductsDisplay_LiberateSeriesClicked); - this.productsDisplay.ConvertToMp3Clicked += new System.EventHandler(this.ProductsDisplay_ConvertToMp3Clicked); + this.productsDisplay.ConvertToMp3Clicked += ProductsDisplay_ConvertToMp3Clicked; this.productsDisplay.InitialLoaded += new System.EventHandler(this.productsDisplay_InitialLoaded); // // toggleQueueHideBtn diff --git a/Source/LibationWinForms/Form1.ProcessQueue.cs b/Source/LibationWinForms/Form1.ProcessQueue.cs index 306aba39..5c2e86c9 100644 --- a/Source/LibationWinForms/Form1.ProcessQueue.cs +++ b/Source/LibationWinForms/Form1.ProcessQueue.cs @@ -23,36 +23,53 @@ namespace LibationWinForms this.Width = width; } - private void ProductsDisplay_LiberateClicked(object sender, LibraryBook libraryBook) + private void ProductsDisplay_LiberateClicked(object sender, LibraryBook[] libraryBooks) { try { - if (libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + if (libraryBooks.Length == 1) { - Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", libraryBook); - SetQueueCollapseState(false); - processBookQueue1.AddDownloadDecrypt(libraryBook); - } - else if (libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - { - Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", libraryBook); - SetQueueCollapseState(false); - processBookQueue1.AddDownloadPdf(libraryBook); - } - else if (libraryBook.Book.Audio_Exists()) - { - // liberated: open explorer to file - var filePath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId); - if (!Go.To.File(filePath?.ShortPathName)) + var item = libraryBooks[0]; + if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) { - var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; - MessageBox.Show($"File not found" + suffix); + Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item); + SetQueueCollapseState(false); + processBookQueue1.AddDownloadDecrypt(item); + } + else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + { + Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); + SetQueueCollapseState(false); + processBookQueue1.AddDownloadPdf(item); + } + else if (item.Book.Audio_Exists()) + { + // liberated: open explorer to file + var filePath = AudibleFileStorage.Audio.GetPath(item.Book.AudibleProductId); + if (!Go.To.File(filePath?.ShortPathName)) + { + var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; + MessageBox.Show($"File not found" + suffix); + } + } + } + else + { + var toLiberate + = libraryBooks + .Where(x => x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + .ToArray(); + + if (toLiberate.Length > 0) + { + SetQueueCollapseState(false); + processBookQueue1.AddDownloadDecrypt(toLiberate); } } } catch (Exception ex) { - Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBook); + Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); } } @@ -72,20 +89,21 @@ namespace LibationWinForms } } - private void ProductsDisplay_ConvertToMp3Clicked(object sender, LibraryBook libraryBook) + private void ProductsDisplay_ConvertToMp3Clicked(object sender, LibraryBook[] libraryBooks) { try { - if (libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated) + var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray(); + if (preLiberated.Length > 0) { - Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", libraryBook); + Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length); SetQueueCollapseState(false); - processBookQueue1.AddConvertMp3(libraryBook); + processBookQueue1.AddConvertMp3(preLiberated); } } catch (Exception ex) { - Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBook); + Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); } } diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 740fb69e..ee8c0176 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -20,9 +20,9 @@ namespace LibationWinForms.GridView /// Number of visible rows has changed public event EventHandler VisibleCountChanged; public event EventHandler RemovableCountChanged; - public event EventHandler LiberateClicked; + public event EventHandler LiberateClicked; public event EventHandler LiberateSeriesClicked; - public event EventHandler ConvertToMp3Clicked; + public event EventHandler ConvertToMp3Clicked; public event EventHandler InitialLoaded; private bool hasBeenDisplayed; @@ -123,12 +123,12 @@ namespace LibationWinForms.GridView #region Cell Context Menu - private void productsGrid_CellContextMenuStripNeeded(IGridEntry entry, ContextMenuStrip ctxMenu) + private void productsGrid_CellContextMenuStripNeeded(IGridEntry[] entries, ContextMenuStrip ctxMenu) { - var ctx = new GridContextMenu(entry, '&'); - #region Liberate all Episodes + var ctx = new GridContextMenu(entries, '&'); + #region Liberate all Episodes (Single series only) - if (entry.Liberate.IsSeries) + if (entries.Length == 1 && entries[0] is ISeriesEntry seriesEntry) { var liberateEpisodesMenuItem = new ToolStripMenuItem() { @@ -136,7 +136,7 @@ namespace LibationWinForms.GridView Enabled = ctx.LiberateEpisodesEnabled }; - liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, (ISeriesEntry)entry); + liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, seriesEntry); ctxMenu.Items.Add(liberateEpisodesMenuItem); } @@ -163,17 +163,10 @@ namespace LibationWinForms.GridView ctxMenu.Items.Add(setNotDownloadMenuItem); #endregion - #region Remove from library + #region Locate file (Single book only) - var removeMenuItem = new ToolStripMenuItem() { Text = ctx.RemoveText }; - removeMenuItem.Click += async (_, _) => await ctx.RemoveAsync(); - ctxMenu.Items.Add(removeMenuItem); - - #endregion - - if (!entry.Liberate.IsSeries) + if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry) { - #region Locate file var locateFileMenuItem = new ToolStripMenuItem() { Text = ctx.LocateFileText }; ctxMenu.Items.Add(locateFileMenuItem); locateFileMenuItem.Click += (_, _) => @@ -194,23 +187,49 @@ namespace LibationWinForms.GridView MessageBoxLib.ShowAdminAlert(this, ctx.LocateFileErrorMessage, ctx.LocateFileErrorMessage, ex); } }; + } - #endregion - #region Convert to Mp3 + #endregion + #region Remove from library + var removeMenuItem = new ToolStripMenuItem() { Text = ctx.RemoveText }; + removeMenuItem.Click += async (_, _) => await ctx.RemoveAsync(); + ctxMenu.Items.Add(removeMenuItem); + + #endregion + #region Liberate All (multiple books only) + if (entries.OfType().Count() > 1) + { + var downloadSelectedMenuItem = new ToolStripMenuItem() + { + Text = ctx.DownloadSelectedText + }; + ctxMenu.Items.Add(downloadSelectedMenuItem); + downloadSelectedMenuItem.Click += (s, _) => + { + LiberateClicked?.Invoke(s, ctx.LibraryBookEntries.Select(e => e.LibraryBook).ToArray()); + }; + } + + #endregion + #region Convert to Mp3 + + if (ctx.LibraryBookEntries.Length > 0) + { var convertToMp3MenuItem = new ToolStripMenuItem { Text = ctx.ConvertToMp3Text, Enabled = ctx.ConvertToMp3Enabled }; - convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook); + convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(this, ctx.LibraryBookEntries.Select(e => e.LibraryBook).ToArray()); ctxMenu.Items.Add(convertToMp3MenuItem); - #endregion } - #region Force Re-Download - if (!entry.Liberate.IsSeries) + #endregion + #region Force Re-Download (Single book only) + + if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry4) { var reDownloadMenuItem = new ToolStripMenuItem() { @@ -220,13 +239,24 @@ namespace LibationWinForms.GridView ctxMenu.Items.Add(reDownloadMenuItem); reDownloadMenuItem.Click += (s, _) => { - //No need to persist this change. It only needs to last long for the file to start downloading - entry.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; - LiberateClicked?.Invoke(s, entry.LibraryBook); + //No need to persist these changes. They only needs to last long for the files to start downloading + entry4.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; + if (entry4.Book.HasPdf()) + entry4.Book.UserDefinedItem.SetPdfStatus(LiberatedStatus.NotLiberated); + + LiberateClicked?.Invoke(s, [entry4.LibraryBook]); }; } + #endregion - #region Edit Templates + + if (entries.Length > 1) + return; + + ctxMenu.Items.Add(new ToolStripSeparator()); + + #region Edit Templates (Single book only) + void editTemplate(LibraryBook libraryBook, string existingTemplate, Action setNewTemplate) where T : Templates, LibationFileManager.ITemplate, new() { @@ -238,14 +268,14 @@ namespace LibationWinForms.GridView } } - if (!entry.Liberate.IsSeries) + if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry2) { var folderTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FolderTemplateText }; var fileTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FileTemplateText }; var multiFileTemplateMenuItem = new ToolStripMenuItem { Text = ctx.MultipartTemplateText }; - folderTemplateMenuItem.Click += (s, _) => editTemplate(entry.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t); - fileTemplateMenuItem.Click += (s, _) => editTemplate(entry.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t); - multiFileTemplateMenuItem.Click += (s, _) => editTemplate(entry.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t); + folderTemplateMenuItem.Click += (s, _) => editTemplate(entry2.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t); + fileTemplateMenuItem.Click += (s, _) => editTemplate(entry2.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t); + multiFileTemplateMenuItem.Click += (s, _) => editTemplate(entry2.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t); var editTemplatesMenuItem = new ToolStripMenuItem { Text = ctx.EditTemplatesText }; editTemplatesMenuItem.DropDownItems.AddRange(new[] { folderTemplateMenuItem, fileTemplateMenuItem, multiFileTemplateMenuItem }); @@ -255,25 +285,22 @@ namespace LibationWinForms.GridView } #endregion + #region View Bookmarks/Clips (Single book only) - ctxMenu.Items.Add(new ToolStripSeparator()); - - #region View Bookmarks/Clips - - if (!entry.Liberate.IsSeries) + if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry3) { var bookRecordMenuItem = new ToolStripMenuItem { Text = ctx.ViewBookmarksText }; - bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this); + bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry3.LibraryBook).ShowDialog(this); ctxMenu.Items.Add(bookRecordMenuItem); } #endregion - #region View All Series + #region View All Series (Single book only) - if (entry.Book.SeriesLink.Any()) + if (entries.Length == 1 && entries[0].Book.SeriesLink.Any()) { var viewSeriesMenuItem = new ToolStripMenuItem { Text = ctx.ViewSeriesText }; - viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show(); + viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entries[0].LibraryBook).Show(); ctxMenu.Items.Add(viewSeriesMenuItem); } @@ -393,7 +420,7 @@ namespace LibationWinForms.GridView { if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error && !liveGridEntry.Liberate.IsUnavailable) - LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook); + LiberateClicked?.Invoke(this, [liveGridEntry.LibraryBook]); } private void productsGrid_RemovableCountChanged(object sender, EventArgs e) diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index c4026d6f..7d0e1fb5 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -11,33 +11,34 @@ using System.Drawing; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; +#nullable enable namespace LibationWinForms.GridView { public delegate void GridEntryClickedEventHandler(IGridEntry liveGridEntry); public delegate void LibraryBookEntryClickedEventHandler(ILibraryBookEntry liveGridEntry); public delegate void GridEntryRectangleClickedEventHandler(IGridEntry liveGridEntry, Rectangle cellRectangle); - public delegate void ProductsGridCellContextMenuStripNeededEventHandler(IGridEntry liveGridEntry, ContextMenuStrip ctxMenu); + public delegate void ProductsGridCellContextMenuStripNeededEventHandler(IGridEntry[] liveGridEntry, ContextMenuStrip ctxMenu); public partial class ProductsGrid : UserControl { /// Number of visible rows has changed - public event EventHandler VisibleCountChanged; - public event LibraryBookEntryClickedEventHandler LiberateClicked; - public event GridEntryClickedEventHandler CoverClicked; - public event LibraryBookEntryClickedEventHandler DetailsClicked; - public event GridEntryRectangleClickedEventHandler DescriptionClicked; - public new event EventHandler Scroll; - public event EventHandler RemovableCountChanged; - public event ProductsGridCellContextMenuStripNeededEventHandler LiberateContextMenuStripNeeded; + public event EventHandler? VisibleCountChanged; + public event LibraryBookEntryClickedEventHandler? LiberateClicked; + public event GridEntryClickedEventHandler? CoverClicked; + public event LibraryBookEntryClickedEventHandler? DetailsClicked; + public event GridEntryRectangleClickedEventHandler? DescriptionClicked; + public new event EventHandler? Scroll; + public event EventHandler? RemovableCountChanged; + public event ProductsGridCellContextMenuStripNeededEventHandler? LiberateContextMenuStripNeeded; - private GridEntryBindingList bindingList; + private GridEntryBindingList? bindingList; internal IEnumerable GetVisibleBooks() => bindingList - .GetFilteredInItems() - .Select(lbe => lbe.LibraryBook); + ?.GetFilteredInItems() + .Select(lbe => lbe.LibraryBook) ?? Enumerable.Empty(); internal IEnumerable GetAllBookEntries() - => bindingList.AllItems().BookEntries(); + => bindingList?.AllItems().BookEntries() ?? Enumerable.Empty(); public ProductsGrid() { @@ -64,11 +65,17 @@ namespace LibationWinForms.GridView [PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))] private void Configuration_FontScaleChanged(object sender, PropertyChangedEventArgsEx e) - => setGridFontScale((float)e.NewValue); + { + if (e.NewValue is float v) + setGridFontScale(v); + } [PropertyChangeFilter(nameof(Configuration.GridScaleFactor))] private void Configuration_ScaleChanged(object sender, PropertyChangedEventArgsEx e) - => setGridScale((float)e.NewValue); + { + if (e.NewValue is float v) + setGridScale(v); + } /// /// Keep track of the original dimensions for rescaling @@ -106,10 +113,13 @@ namespace LibationWinForms.GridView #endregion - private void GridEntryDataGridView_CellContextMenuStripNeeded(object sender, DataGridViewCellContextMenuStripNeededEventArgs e) + private static string? RemoveLineBreaks(string? text) + => text?.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' '); + + private void GridEntryDataGridView_CellContextMenuStripNeeded(object? sender, DataGridViewCellContextMenuStripNeededEventArgs e) { // header - if (e.RowIndex < 0) + if (e.RowIndex < 0 || sender is not DataGridView dgv) return; e.ContextMenuStrip = new ContextMenuStrip(); @@ -120,25 +130,89 @@ namespace LibationWinForms.GridView { try { - var dgv = (DataGridView)sender; - var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString(); - Clipboard.SetDataObject(text, false, 5, 150); + string clipboardText; + + if (dgv.SelectedCells.Count <= 1) + { + //Copy contents only of cell that was right-clicked on. + clipboardText = dgv[e.ColumnIndex, e.RowIndex].FormattedValue?.ToString() ?? string.Empty; + } + else + { + //Copy contents of selected cells. Each row is a new line, + //and columns are separated with tabs. Similar formatting to Microsoft Excel. + var selectedCells + = dgv.SelectedCells + .OfType() + .Where(c => c.OwningColumn is not null && c.OwningRow is not null) + .OrderBy(c => c.RowIndex) + .ThenBy(c => c.OwningColumn!.DisplayIndex) + .ToList(); + + var headerText + = string.Join("\t", + selectedCells + .Select(c => c.OwningColumn) + .Distinct() + .Select(c => RemoveLineBreaks(c?.HeaderText)) + .OfType()); + + List linesOfText = [headerText]; + foreach (var distinctRow in selectedCells.Select(c => c.RowIndex).Distinct()) + { + linesOfText.Add(string.Join("\t", + selectedCells + .Where(c => c.RowIndex == distinctRow) + .Select(c => RemoveLineBreaks(c.FormattedValue?.ToString()) ?? string.Empty) + )); + } + clipboardText = string.Join(Environment.NewLine, linesOfText); + } + Clipboard.SetDataObject(clipboardText, false, 5, 150); + } + catch(Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error copying text to clipboard"); } - catch { } }); e.ContextMenuStrip.Items.Add(new ToolStripSeparator()); } - var entry = getGridEntry(e.RowIndex); - var name = gridEntryDataGridView.Columns[e.ColumnIndex].DataPropertyName; - LiberateContextMenuStripNeeded?.Invoke(entry, e.ContextMenuStrip); + var clickedEntry = getGridEntry(e.RowIndex); + + var allSelected + = gridEntryDataGridView + .SelectedCells + .OfType() + .Select(c => c.OwningRow) + .OfType() + .Distinct() + .OrderBy(r => r.Index) + .Select(r => r.DataBoundItem) + .OfType() + .ToArray(); + + var clickedIndex = Array.IndexOf(allSelected, clickedEntry); + if (clickedIndex == -1) + { + //User didn't right-click on a selected cell + gridEntryDataGridView.ClearSelection(); + gridEntryDataGridView[e.ColumnIndex, e.RowIndex].Selected = true; + allSelected = [clickedEntry]; + } + else if (clickedIndex > 0) + { + //Ensure the clicked entry is first in the list + (allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]); + } + LiberateContextMenuStripNeeded?.Invoke(allSelected, e.ContextMenuStrip); } private void EnableDoubleBuffering() { var propertyInfo = gridEntryDataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - propertyInfo.SetValue(gridEntryDataGridView, true, null); + propertyInfo?.SetValue(gridEntryDataGridView, true, null); } #region Button controls @@ -167,11 +241,11 @@ namespace LibationWinForms.GridView if (e.ColumnIndex == liberateGVColumn.Index) { if (sEntry.Liberate.Expanded) - bindingList.CollapseItem(sEntry); + bindingList?.CollapseItem(sEntry); else - bindingList.ExpandItem(sEntry); + bindingList?.ExpandItem(sEntry); - VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); + VisibleCountChanged?.Invoke(this, bindingList?.GetFilteredInItems().Count() ?? 0); } else if (e.ColumnIndex == descriptionGVColumn.Index) DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); @@ -202,7 +276,7 @@ namespace LibationWinForms.GridView get => removeGVColumn.Visible; set { - if (value) + if (value && bindingList is not null) { foreach (var book in bindingList.AllItems()) book.Remove = false; @@ -248,13 +322,16 @@ namespace LibationWinForms.GridView internal void UpdateGrid(List dbBooks) { + if (bindingList == null) + throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(UpdateGrid)}"); + //First row that is in view in the DataGridView var topRow = gridEntryDataGridView.Rows.Cast().FirstOrDefault(r => r.Displayed)?.Index ?? 0; #region Add new or update existing grid entries - //Remove filter prior to adding/updating boooks - string existingFilter = syncBindingSource.Filter; + //Remove filter prior to adding/updating books + string? existingFilter = syncBindingSource.Filter; Filter(null); //Add absent entries to grid, or update existing entry @@ -308,6 +385,9 @@ namespace LibationWinForms.GridView public void RemoveBooks(IEnumerable removedBooks) { + if (bindingList == null) + throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(RemoveBooks)}"); + //Remove books in series from their parents' Children list foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode)) removed.Parent.RemoveChild(removed); @@ -325,8 +405,11 @@ namespace LibationWinForms.GridView VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); } - private void AddOrUpdateBook(LibraryBook book, ILibraryBookEntry existingBookEntry) + private void AddOrUpdateBook(LibraryBook book, ILibraryBookEntry? existingBookEntry) { + if (bindingList == null) + throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateBook)}"); + if (existingBookEntry is null) // Add the new product to top bindingList.Insert(0, new LibraryBookEntry(book)); @@ -335,8 +418,11 @@ namespace LibationWinForms.GridView existingBookEntry.UpdateLibraryBook(book); } - private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) + private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry? existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) { + if (bindingList == null) + throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateEpisode)}"); + if (existingEpisodeEntry is null) { ILibraryBookEntry episodeEntry; @@ -397,7 +483,7 @@ namespace LibationWinForms.GridView #region Filter - public void Filter(string searchString) + public void Filter(string? searchString) { if (bindingList is null) return; @@ -485,16 +571,16 @@ namespace LibationWinForms.GridView removeGVColumn.IndeterminateValue = null; } - private void HideMenuItem_Click(object sender, EventArgs e) + private void HideMenuItem_Click(object? sender, EventArgs e) { var menuItem = sender as ToolStripMenuItem; - var propertyName = menuItem.Tag as string; + var propertyName = menuItem?.Tag as string; var column = gridEntryDataGridView.Columns .Cast() .FirstOrDefault(c => c.DataPropertyName == propertyName); - if (column != null) + if (column != null && menuItem != null && propertyName != null) { var visible = menuItem.Checked; menuItem.Checked = !visible; @@ -508,7 +594,7 @@ namespace LibationWinForms.GridView } } - private void gridEntryDataGridView_ColumnDisplayIndexChanged(object sender, DataGridViewColumnEventArgs e) + private void gridEntryDataGridView_ColumnDisplayIndexChanged(object? sender, DataGridViewColumnEventArgs e) { var config = Configuration.Instance; @@ -517,7 +603,7 @@ namespace LibationWinForms.GridView config.GridColumnsDisplayIndices = dictionary; } - private void gridEntryDataGridView_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e) + private void gridEntryDataGridView_CellToolTipTextNeeded(object? sender, DataGridViewCellToolTipTextNeededEventArgs e) { if (e.ColumnIndex == descriptionGVColumn.Index) e.ToolTipText = "Click to see full description"; @@ -525,7 +611,7 @@ namespace LibationWinForms.GridView e.ToolTipText = "Click to see full size"; } - private void gridEntryDataGridView_ColumnWidthChanged(object sender, DataGridViewColumnEventArgs e) + private void gridEntryDataGridView_ColumnWidthChanged(object? sender, DataGridViewColumnEventArgs e) { var config = Configuration.Instance;