From 659f793eb81dbe401e58b25b6766a2f13745f889 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 9 Jan 2026 16:47:37 -0700 Subject: [PATCH] Improve TrashBinDialog functionality - Use the main display grid control to display deleted books - Added search functionality for deleted books. This required creating a temporary search index in the `InProgress` folder. The products grid control now uses an instance of `ISearchEngine` to filter its grid entries. The main grid uses a singleton instance of `MainSearchEngine`, which merely wraps `SearchEngineCommands.Search()`. The TrashBinDialogs use `TempSearchEngine`. - Users can now batch select `Everyting` as well as `Audible Plus Books` Avalonia: - Refactor main grid context menus to no longer require reflection --- Source/ApplicationServices/ISearchEngine.cs | 9 + .../ApplicationServices/MainSearchEngine.cs | 16 ++ .../ApplicationServices/TempSearchEngine.cs | 45 +++ .../QueryObjects/LibraryBookQueries.cs | 3 +- .../ViewModels/TrashBinViewModel.cs | 2 +- Source/HangoverWinForms/Form1.Deleted.cs | 2 +- .../Controls/DataGridContextMenus.cs | 188 ++++++------ .../Controls/DataGridMyRatingColumn.cs | 2 +- .../Controls/DataGridTemplateColumnExt.cs | 2 +- .../Dialogs/TrashBinDialog.axaml | 67 +++-- .../Dialogs/TrashBinDialog.axaml.cs | 269 ++++++++++-------- Source/LibationAvalonia/ViewModels/MainVM.cs | 2 +- .../ViewModels/ProductsDisplayViewModel.cs | 31 +- .../Views/ProductsDisplay.axaml | 7 +- .../Views/ProductsDisplay.axaml.cs | 73 ++++- Source/LibationSearchEngine/SearchEngine.cs | 18 +- .../GridView/QueryExtensions.cs | 11 +- .../Dialogs/TrashBinDialog.Designer.cs | 118 ++++++-- .../Dialogs/TrashBinDialog.cs | 174 ++++++----- .../Dialogs/TrashBinDialog.resx | 62 +++- .../GridView/GridEntryBindingList.cs | 29 +- .../GridView/ProductsDisplay.cs | 3 +- .../LibationWinForms/GridView/ProductsGrid.cs | 75 +++-- 23 files changed, 795 insertions(+), 413 deletions(-) create mode 100644 Source/ApplicationServices/ISearchEngine.cs create mode 100644 Source/ApplicationServices/MainSearchEngine.cs create mode 100644 Source/ApplicationServices/TempSearchEngine.cs diff --git a/Source/ApplicationServices/ISearchEngine.cs b/Source/ApplicationServices/ISearchEngine.cs new file mode 100644 index 00000000..d7e171e9 --- /dev/null +++ b/Source/ApplicationServices/ISearchEngine.cs @@ -0,0 +1,9 @@ +using LibationSearchEngine; + +#nullable enable +namespace ApplicationServices; + +public interface ISearchEngine +{ + SearchResultSet? GetSearchResultSet(string? searchString); +} diff --git a/Source/ApplicationServices/MainSearchEngine.cs b/Source/ApplicationServices/MainSearchEngine.cs new file mode 100644 index 00000000..52783801 --- /dev/null +++ b/Source/ApplicationServices/MainSearchEngine.cs @@ -0,0 +1,16 @@ +using LibationSearchEngine; + +#nullable enable +namespace ApplicationServices; + +/// +/// The main search engine used Libation. +/// Acts as an adapter to SearchEngineCommands.Search() +/// +public class MainSearchEngine : ISearchEngine +{ + public static MainSearchEngine Instance { get; } = new MainSearchEngine(); + private MainSearchEngine() { } + public SearchResultSet? GetSearchResultSet(string? searchString) + => string.IsNullOrEmpty(searchString) ? null : SearchEngineCommands.Search(searchString); +} diff --git a/Source/ApplicationServices/TempSearchEngine.cs b/Source/ApplicationServices/TempSearchEngine.cs new file mode 100644 index 00000000..527f67fe --- /dev/null +++ b/Source/ApplicationServices/TempSearchEngine.cs @@ -0,0 +1,45 @@ +using DataLayer; +using LibationFileManager; +using LibationSearchEngine; +using System.Collections.Generic; + +#nullable enable +namespace ApplicationServices; + +/// +/// A temporary search engine created in InProgress/TempSearchEngine +/// Used for Trash Bin searches to avoid interfering with the main search engine +/// +public class TempSearchEngine : ISearchEngine +{ + public static string SearchEnginePath { get; } + = System.IO.Path.Combine(Configuration.Instance.InProgress, nameof(TempSearchEngine)); + private SearchEngine SearchEngine { get; } = new SearchEngine(SearchEnginePath); + + public bool ReindexSearchEngine(IEnumerable books) + { + try + { + SearchEngine.CreateNewIndex(books, overwrite: true); + return true; + } + catch + { + return false; + } + } + public SearchResultSet? GetSearchResultSet(string? searchString) + { + if (string.IsNullOrEmpty(searchString)) + return null; + + try + { + return SearchEngine.Search(searchString); + } + catch + { + return null; + } + } +} diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index 5a2275ab..7e710666 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -47,7 +47,8 @@ namespace DataLayer => context .LibraryBooks .AsNoTrackingWithIdentityResolution() - .Where(lb => lb.IsDeleted) + //Return all parents so the trash bin grid can show podcasts beneath their parents + .Where(lb => lb.IsDeleted || lb.Book.ContentType == ContentType.Parent) .getLibrary() .ToList(); diff --git a/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs b/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs index da2ea8fa..009e67ea 100644 --- a/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs +++ b/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs @@ -97,7 +97,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable public void Reload() { - var deletedBooks = DbContexts.GetDeletedLibraryBooks(); + var deletedBooks = DbContexts.GetDeletedLibraryBooks().Where(lb => lb.Book.ContentType is not ContentType.Parent); DeletedBooks.Clear(); DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb })); diff --git a/Source/HangoverWinForms/Form1.Deleted.cs b/Source/HangoverWinForms/Form1.Deleted.cs index e48bd511..40d6fef1 100644 --- a/Source/HangoverWinForms/Form1.Deleted.cs +++ b/Source/HangoverWinForms/Form1.Deleted.cs @@ -54,7 +54,7 @@ namespace HangoverWinForms deletedCbl.Items.Clear(); List deletedBooks = DbContexts.GetDeletedLibraryBooks(); - foreach (var lb in deletedBooks) + foreach (var lb in deletedBooks.Where(lb => lb.Book.ContentType is not ContentType.Parent)) deletedCbl.Items.Add(lb); setLabel(); diff --git a/Source/LibationAvalonia/Controls/DataGridContextMenus.cs b/Source/LibationAvalonia/Controls/DataGridContextMenus.cs index 4e0a7b2d..2bddde98 100644 --- a/Source/LibationAvalonia/Controls/DataGridContextMenus.cs +++ b/Source/LibationAvalonia/Controls/DataGridContextMenus.cs @@ -1,119 +1,121 @@ using Avalonia.Collections; using Avalonia.Controls; -using LibationUiBase.GridView; +using Avalonia.Input; using System; +using System.Collections.Generic; using System.Linq; -using System.Reflection; -namespace LibationAvalonia.Controls +namespace LibationAvalonia.Controls; + +public class DataGridCellContextMenu where TContext : class { - internal static class DataGridContextMenus + public static DataGridCellContextMenu? Create(ContextMenu? contextMenu) { - public static event EventHandler? CellContextMenuStripNeeded; - private static readonly ContextMenu ContextMenu = new(); - public static readonly AvaloniaList MenuItems = new(); - private static readonly PropertyInfo OwningColumnProperty; - private static readonly PropertyInfo OwningGridProperty; - - static DataGridContextMenus() + DataGrid? grid = null; + DataGridCell? cell = null; + var parent = contextMenu?.Parent; + while (parent is not null && grid is null) { - ContextMenu.ItemsSource = MenuItems; - OwningColumnProperty = typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException("Could not find OwningColumn property on DataGridCell"); - OwningGridProperty = typeof(DataGridColumn).GetProperty("OwningGrid", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException("Could not find OwningGrid property on DataGridColumn"); + grid ??= parent as DataGrid; + cell ??= parent as DataGridCell; + + parent = parent.Parent; } - public static void AttachContextMenu(this DataGridCell cell) + if (grid is null || cell is null || cell.Tag is not DataGridColumn column || contextMenu!.DataContext is not TContext clickedEntry) + return null; + + var allSelected = grid.SelectedItems.OfType().ToArray(); + var clickedIndex = Array.IndexOf(allSelected, clickedEntry); + if (clickedIndex == -1) { - if (cell is not null && cell.ContextMenu is null) - { - cell.ContextRequested += Cell_ContextRequested; - cell.ContextMenu = ContextMenu; - } + //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]); } - private static void Cell_ContextRequested(object? sender, ContextRequestedEventArgs e) + return new DataGridCellContextMenu(contextMenu, grid, column, allSelected); + } + + public string CellClipboardContents + { + get { - if (sender is DataGridCell cell && - cell.DataContext is GridEntry clickedEntry && - OwningColumnProperty.GetValue(cell) is DataGridColumn column && - OwningGridProperty.GetValue(column) is DataGrid grid) + var lines = GetClipboardLines(getClickedCell: true); + return lines.Count >= 1 ? lines[0] : string.Empty; + } + } + public string GetRowClipboardContents() => string.Join(Environment.NewLine, GetClipboardLines(false)); + + public ContextMenu ContextMenu { get; } + public DataGrid Grid { get; } + public DataGridColumn Column { get; } + public TContext[] RowItems { get; } + public AvaloniaList ContextMenuItems { get; } + + private DataGridCellContextMenu(ContextMenu contextMenu, DataGrid grid, DataGridColumn column, TContext[] rowItems) + { + Grid = grid; + Column = column; + RowItems = rowItems; + ContextMenu = contextMenu; + ContextMenuItems = contextMenu.ItemsSource as AvaloniaList ?? new(); + contextMenu.ItemsSource = ContextMenuItems; + ContextMenuItems.Clear(); + } + + private List GetClipboardLines(bool getClickedCell) + { + if (RowItems is null || RowItems.Length == 0) + return []; + + List lines = []; + Grid.CopyingRowClipboardContent += Grid_CopyingRowClipboardContent; + Grid.RaiseEvent(GetCopyEventArgs()); + Grid.CopyingRowClipboardContent -= Grid_CopyingRowClipboardContent; + return lines; + + void Grid_CopyingRowClipboardContent(object? sender, DataGridRowClipboardEventArgs e) + { + if (getClickedCell) { - var allSelected = grid.SelectedItems.OfType().ToArray(); - var clickedIndex = Array.IndexOf(allSelected, clickedEntry); - if (clickedIndex == -1) + if (e.IsColumnHeadersRow) + return; + var cellContent = e.ClipboardRowContent.FirstOrDefault(c => c.Column == Column); + if (cellContent.Column is not null) { - //User didn't right-click on a selected cell - grid.SelectedItem = clickedEntry; - allSelected = [clickedEntry]; + lines.Add(cellContent.Content?.ToString() ?? string.Empty); } - 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 = column, - Grid = grid, - GridEntries = allSelected, - ContextMenu = ContextMenu - }; - - args.ContextMenuItems.Clear(); - CellContextMenuStripNeeded?.Invoke(sender, args); - e.Handled = args.ContextMenuItems.Count == 0; } + else if (e.Item == RowItems[0]) + lines.Insert(1, FormatClipboardRowContent(e)); else - e.Handled = true; + lines.Add(FormatClipboardRowContent(e)); + + //Clear so that the DataGrid copy implementation doesn't set the clipboard + e.ClipboardRowContent.Clear(); } } - public class DataGridCellContextMenuStripNeededEventArgs + private static KeyEventArgs GetCopyEventArgs() => new() { - private static readonly MethodInfo GetCellValueMethod; - static DataGridCellContextMenuStripNeededEventArgs() - { - GetCellValueMethod = typeof(DataGridColumn).GetMethod("GetCellValue", BindingFlags.NonPublic | BindingFlags.Instance) - ?? throw new InvalidOperationException("Could not find GetCellValue method on DataGridColumn"); - } + Key = Key.C, + KeyModifiers = KeyModifiers.Control, + Route = Avalonia.Interactivity.RoutingStrategies.Bubble, + PhysicalKey = PhysicalKey.C, + KeySymbol = "c", + KeyDeviceType = KeyDeviceType.Keyboard, + RoutedEvent = InputElement.KeyDownEvent + }; - private static string GetCellValue(DataGridColumn column, object item) - => GetCellValueMethod.Invoke(column, new object[] { item, column.ClipboardContentBinding })?.ToString() ?? ""; + private string FormatClipboardRowContent(DataGridRowClipboardEventArgs e) + => string.Join("\t", e.ClipboardRowContent.Select(c => RemoveLineBreaks(c.Content?.ToString()))); + private static string RemoveLineBreaks(string? text) + => text?.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ') ?? ""; - 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(GridEntry 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 GridEntry[] GridEntries { get; init; } - public required ContextMenu ContextMenu { get; init; } - public AvaloniaList ContextMenuItems => DataGridContextMenus.MenuItems; - } } diff --git a/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs b/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs index 8ff392e7..5062ccd1 100644 --- a/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs +++ b/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs @@ -25,7 +25,7 @@ namespace LibationAvalonia.Controls IsEditingMode = false }; - cell?.AttachContextMenu(); + cell.Tag = this; if (!IsReadOnly) ToolTip.SetTip(myRatingElement, "Click to change ratings"); diff --git a/Source/LibationAvalonia/Controls/DataGridTemplateColumnExt.cs b/Source/LibationAvalonia/Controls/DataGridTemplateColumnExt.cs index c65358aa..cda02234 100644 --- a/Source/LibationAvalonia/Controls/DataGridTemplateColumnExt.cs +++ b/Source/LibationAvalonia/Controls/DataGridTemplateColumnExt.cs @@ -6,7 +6,7 @@ namespace LibationAvalonia.Controls { protected override Control GenerateElement(DataGridCell cell, object dataItem) { - cell?.AttachContextMenu(); + cell.Tag = this; return base.GenerateElement(cell, dataItem); } } diff --git a/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml b/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml index 34f200b4..891504a5 100644 --- a/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml @@ -4,49 +4,72 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="630" d:DesignHeight="480" x:Class="LibationAvalonia.Dialogs.TrashBinDialog" - xmlns:controls="clr-namespace:LibationAvalonia.Controls" - MinWidth="630" MinHeight="480" - Width="630" Height="480" + xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs" + xmlns:views="clr-namespace:LibationAvalonia.Views" + x:DataType="dialogs:TrashBinViewModel" + x:CompileBindings="True" + MinWidth="680" MinHeight="480" + Width="680" Height="480" Title="Trash Bin" WindowStartupLocation="CenterOwner" Icon="/Assets/libation.ico"> - + - + - + + + + + + + +