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
This commit is contained in:
Michael Bucari-Tovo
2026-01-09 16:47:37 -07:00
parent 6f7cd4d5b5
commit 659f793eb8
23 changed files with 795 additions and 413 deletions

View File

@@ -0,0 +1,9 @@
using LibationSearchEngine;
#nullable enable
namespace ApplicationServices;
public interface ISearchEngine
{
SearchResultSet? GetSearchResultSet(string? searchString);
}

View File

@@ -0,0 +1,16 @@
using LibationSearchEngine;
#nullable enable
namespace ApplicationServices;
/// <summary>
/// The main search engine used Libation.
/// Acts as an adapter to SearchEngineCommands.Search()
/// </summary>
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);
}

View File

@@ -0,0 +1,45 @@
using DataLayer;
using LibationFileManager;
using LibationSearchEngine;
using System.Collections.Generic;
#nullable enable
namespace ApplicationServices;
/// <summary>
/// A temporary search engine created in InProgress/TempSearchEngine
/// Used for Trash Bin searches to avoid interfering with the main search engine
/// </summary>
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<LibraryBook> 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;
}
}
}

View File

@@ -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();

View File

@@ -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 }));

View File

@@ -54,7 +54,7 @@ namespace HangoverWinForms
deletedCbl.Items.Clear();
List<LibraryBook> 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();

View File

@@ -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<TContext> where TContext : class
{
internal static class DataGridContextMenus
public static DataGridCellContextMenu<TContext>? Create(ContextMenu? contextMenu)
{
public static event EventHandler<DataGridCellContextMenuStripNeededEventArgs>? CellContextMenuStripNeeded;
private static readonly ContextMenu ContextMenu = new();
public static readonly AvaloniaList<Control> 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<TContext>().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<TContext>(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<Control> 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<Control> ?? new();
contextMenu.ItemsSource = ContextMenuItems;
ContextMenuItems.Clear();
}
private List<string> GetClipboardLines(bool getClickedCell)
{
if (RowItems is null || RowItems.Length == 0)
return [];
List<string> 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<GridEntry>().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<Control> ContextMenuItems => DataGridContextMenus.MenuItems;
}
}

View File

@@ -25,7 +25,7 @@ namespace LibationAvalonia.Controls
IsEditingMode = false
};
cell?.AttachContextMenu();
cell.Tag = this;
if (!IsReadOnly)
ToolTip.SetTip(myRatingElement, "Click to change ratings");

View File

@@ -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);
}
}

View File

@@ -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">
<Grid
RowDefinitions="Auto,*,Auto">
<Grid Margin="5"
RowDefinitions="Auto,Auto,*,Auto">
<TextBlock
Grid.Row="0"
Margin="5"
Text="Check books you want to permanently delete from or restore to Libation" />
<TextBlock Text="Check books you want to permanently delete from or restore to Libation"/>
<controls:CheckedListBox
Grid.Row="1"
Margin="5,0,5,0"
BorderThickness="1"
BorderBrush="Gray"
IsEnabled="{Binding ControlsEnabled}"
Items="{Binding DeletedBooks}" />
<Grid Margin="0,5" Grid.Row="1" Grid.ColumnDefinitions="Auto,*,Auto">
<TextBlock VerticalAlignment="Center" Text="Search Deleted Books:"/>
<TextBox Name="searchTb" Margin="5,0" Grid.Column="1" Text="{Binding FilterString}" IsEnabled="{Binding ControlsEnabled}">
<TextBox.KeyBindings>
<KeyBinding Command="{Binding FilterBtnAsync}" Gesture="Enter" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="SaveButton" Grid.Column="2" Command="{Binding FilterBtnAsync}" IsEnabled="{Binding ControlsEnabled}" VerticalAlignment="Stretch" Content="Filter" />
</Grid>
<views:ProductsDisplay
Grid.Row="2"
DisableContextMenu="True"
DisableColumnCustomization="True"
IsEnabled="{Binding $parent.((dialogs:TrashBinViewModel)DataContext).ControlsEnabled}"
DataContext="{Binding ProductsDisplay}" />
<Grid
Grid.Row="2"
Margin="5"
ColumnDefinitions="Auto,Auto,*,Auto">
Margin="0,5,0,0"
Grid.Row="3"
ColumnDefinitions="Auto,Auto,Auto,Auto,*,Auto">
<CheckBox
IsEnabled="{Binding ControlsEnabled}"
IsThreeState="True"
Margin="0,0,20,0"
Margin="0,0,14,0"
IsChecked="{Binding EverythingChecked}"
Content="Everything" />
<TextBlock
Grid.Column="1"
Margin="0,0,14,0"
VerticalAlignment="Center"
Text="{Binding CheckedCountText}" />
<CheckBox
Grid.Column="2"
IsEnabled="{Binding ControlsEnabled}"
IsThreeState="True"
Margin="0,0,15,0"
IsChecked="{Binding AudiblePlusChecked}"
Content="Audible Plus Books" />
<TextBlock
Grid.Column="3"
VerticalAlignment="Center"
Text="{Binding AudiblePlusCheckedCountText}" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="2"
Grid.Column="4"
Margin="0,0,20,0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
@@ -56,7 +79,7 @@
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="3"
Grid.Column="5"
Command="{Binding PermanentlyDeleteCheckedAsync}" >
<TextBlock
TextAlignment="Center"

View File

@@ -1,140 +1,159 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using DataLayer;
using LibationAvalonia.Controls;
using Dinah.Core.Collections.Generic;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
namespace LibationAvalonia.Dialogs;
public partial class TrashBinDialog : DialogWindow
{
public partial class TrashBinDialog : DialogWindow
private TrashBinViewModel VM { get; }
public TrashBinDialog()
{
public TrashBinDialog()
{
InitializeComponent();
DataContext = new TrashBinViewModel();
InitializeComponent();
SaveOnEnter = false;
ControlToFocusOnShow = searchTb;
DataContext = VM = new TrashBinViewModel();
this.Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
KeyBindings.Add(new Avalonia.Input.KeyBinding
{
Gesture = new Avalonia.Input.KeyGesture(Avalonia.Input.Key.Escape),
Command = ReactiveCommand.Create(Close)
});
}
}
public class TrashBinViewModel : ViewModelBase, IDisposable
{
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
public bool ControlsEnabled { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = true;
private bool? everythingChecked = false;
public bool? EverythingChecked
{
get => everythingChecked;
set
{
everythingChecked = value ?? false;
if (everythingChecked is true)
CheckAll();
else if (everythingChecked is false)
UncheckAll();
}
}
private int _totalBooksCount = 0;
private int _checkedBooksCount = -1;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
if (_checkedBooksCount != value)
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
everythingChecked
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
: _checkedBooksCount == _totalBooksCount ? true
: null;
this.RaisePropertyChanged(nameof(EverythingChecked));
}
}
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
public TrashBinViewModel()
{
DeletedBooks = new()
{
ResetBehavior = ResetBehavior.Remove
};
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
Reload();
}
public void CheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = true;
}
public void UncheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = false;
}
public async Task RestoreCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await CheckedBooks.RestoreBooksAsync();
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
public async Task PermanentlyDeleteCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await CheckedBooks.PermanentlyDeleteBooksAsync();
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
private void Reload()
{
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
_totalBooksCount = DeletedBooks.Count;
CheckedBooksCount = 0;
}
private IDisposable tracker;
private void CheckboxPropertyChanged(Tuple<object?, PropertyChangedEventArgs> e)
{
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
}
public void Dispose() => tracker?.Dispose();
Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
Loaded += async (_, _) => await VM.InitAsync();
}
}
public class TrashBinViewModel : ViewModelBase
{
private TempSearchEngine SearchEngine { get; } = new();
public ProductsDisplayViewModel ProductsDisplay { get; }
public string? CheckedCountText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string? AudiblePlusCheckedCountText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public bool ControlsEnabled { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string? FilterString { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
private bool? m_everythingChecked = false;
private bool? m_audiblePlusChecked = false;
public bool? EverythingChecked
{
get => m_everythingChecked;
set
{
m_everythingChecked = value ?? false;
SetVisibleChecked(_ => true, m_everythingChecked.Value);
}
}
public bool? AudiblePlusChecked
{
get => m_audiblePlusChecked;
set
{
m_audiblePlusChecked = value ?? false;
SetVisibleChecked(e => e.IsAudiblePlus, m_audiblePlusChecked.Value);
}
}
public TrashBinViewModel()
{
ProductsDisplay = new() { SearchEngine = SearchEngine };
ProductsDisplay.RemovableCountChanged += (_, _) => UpdateCounts();
ProductsDisplay.VisibleCountChanged += (_, _) => UpdateCounts();
}
public async Task InitAsync()
{
var deletedBooks = GetDeletedLibraryBooks();
SearchEngine.ReindexSearchEngine(deletedBooks);
await ProductsDisplay.BindToGridAsync(deletedBooks);
await ProductsDisplay.ScanAndRemoveBooksAsync();
ControlsEnabled = true;
}
private async Task ReloadAsync()
{
var deletedBooks = GetDeletedLibraryBooks();
SearchEngine.ReindexSearchEngine(deletedBooks);
await ProductsDisplay.UpdateGridAsync(deletedBooks);
}
public void SetVisibleChecked(Func<LibraryBook, bool> predicate, bool isChecked)
{
ProductsDisplay.GetVisibleGridEntries().Where(e => predicate(e.LibraryBook)).ForEach(i => i.Remove = isChecked);
}
private IEnumerable<LibraryBook> GetCheckedBooks() => ProductsDisplay.GetVisibleGridEntries().Where(i => i.Remove is true).Select(i => i.LibraryBook);
private void UpdateCounts()
{
var visible = ProductsDisplay.GetVisibleGridEntries().ToArray();
var plusVisibleCount = visible.Count(e => e.LibraryBook.IsAudiblePlus);
var checkedCount = visible.Count(e => e.Remove is true);
var plusCheckedCount = visible.Count(e => e.LibraryBook.IsAudiblePlus && e.Remove is true);
CheckedCountText = $"Checked: {checkedCount} of {visible.Length}";
AudiblePlusCheckedCountText = $"Checked: {plusCheckedCount} of {plusVisibleCount}";
bool? everythingChecked = checkedCount == 0 || visible.Length == 0 ? false
: checkedCount == visible.Length ? true
: null;
bool? audiblePlusChecked = plusCheckedCount == 0 || plusVisibleCount == 0 ? false
: plusCheckedCount == plusVisibleCount ? true
: null;
this.RaiseAndSetIfChanged(ref m_everythingChecked, everythingChecked, nameof(EverythingChecked));
this.RaiseAndSetIfChanged(ref m_audiblePlusChecked, audiblePlusChecked, nameof(AudiblePlusChecked));
}
public async Task FilterBtnAsync()
{
var lastGood = ProductsDisplay.FilterString;
try
{
await ProductsDisplay.Filter(FilterString);
}
catch
{
await ProductsDisplay.Filter(lastGood);
FilterString = lastGood;
}
}
public async Task RestoreCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await GetCheckedBooks().RestoreBooksAsync();
if (qtyChanges > 0)
await ReloadAsync();
ControlsEnabled = true;
}
public async Task PermanentlyDeleteCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await GetCheckedBooks().PermanentlyDeleteBooksAsync();
if (qtyChanges > 0)
await ReloadAsync();
ControlsEnabled = true;
}
private static List<LibraryBook> GetDeletedLibraryBooks()
{
#if DEBUG
if (Avalonia.Controls.Design.IsDesignMode)
{
return [
MockLibraryBook.CreateBook(title: "Mock Audible Plus Library Book 4", isAudiblePlus: true),
MockLibraryBook.CreateBook(title: "Mock Audible Plus Library Book 3", isAudiblePlus: true),
MockLibraryBook.CreateBook(title: "Mock Library Book 2"),
MockLibraryBook.CreateBook(title: "Mock Library Book 1"),
];
}
#endif
return DbContexts.GetDeletedLibraryBooks();
}
}

View File

@@ -13,7 +13,7 @@ namespace LibationAvalonia.ViewModels
{
public Task? BindToGridTask { get; set; }
public ProcessQueueViewModel ProcessQueue { get; } = new ProcessQueueViewModel();
public ProductsDisplayViewModel ProductsDisplay { get; } = new ProductsDisplayViewModel();
public ProductsDisplayViewModel ProductsDisplay { get; } = new() { SearchEngine = MainSearchEngine.Instance };
public double? DownloadProgress { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }

View File

@@ -34,18 +34,12 @@ namespace LibationAvalonia.ViewModels
public bool RemoveColumnVisible { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); }
public List<LibraryBook> GetVisibleBookEntries()
=> FilteredInGridEntries?
.OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList()
?? SOURCE
.OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList();
=> GetVisibleGridEntries().Select(lbe => lbe.LibraryBook).ToList();
private IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> SOURCE
.BookEntries();
public IEnumerable<LibraryBookEntry> GetVisibleGridEntries()
=> (FilteredInGridEntries as IEnumerable<GridEntry> ?? SOURCE).OfType<LibraryBookEntry>();
private IEnumerable<LibraryBookEntry> GetAllBookEntries() => SOURCE.BookEntries();
public ProductsDisplayViewModel()
{
@@ -53,6 +47,8 @@ namespace LibationAvalonia.ViewModels
VisibleCountChanged?.Invoke(this, 0);
}
public ISearchEngine? SearchEngine { get; set; }
private static readonly System.Reflection.MethodInfo? SetFlagsMethod;
/// <summary>
@@ -120,7 +116,8 @@ namespace LibationAvalonia.ViewModels
}
//Create the filtered-in list before adding entries to GridEntries to avoid a refresh or UI action
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString);
var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString);
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(searchResultSet);
// Adding SOURCE to the DataGridViewCollection _after_ building the SOURCE list
//Saves ~500 ms on a library of ~4500 books.
@@ -315,7 +312,8 @@ namespace LibationAvalonia.ViewModels
if (SOURCE.Count == 0)
return;
FilteredInGridEntries = SOURCE.FilterEntries(searchString);
var results = SearchEngine?.GetSearchResultSet(searchString);
FilteredInGridEntries = SOURCE.FilterEntries(results);
await refreshGrid();
}
@@ -334,7 +332,9 @@ namespace LibationAvalonia.ViewModels
private async void SearchEngineCommands_SearchEngineUpdated(object? sender, EventArgs? e)
{
var filterResults = SOURCE.FilterEntries(FilterString);
var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString);
var filterResults = SOURCE.FilterEntries(searchResultSet);
if (FilteredInGridEntries.SearchSetsDiffer(filterResults))
{
@@ -454,7 +454,7 @@ namespace LibationAvalonia.ViewModels
#endregion
#region Column Widths
public bool DisablePersistColumnWidths { get; set; }
public DataGridLength TitleWidth { get => getColumnWidth("Title", 200); set => setColumnWidth("Title", value); }
public DataGridLength AuthorsWidth { get => getColumnWidth("Authors", 100); set => setColumnWidth("Authors", value); }
public DataGridLength NarratorsWidth { get => getColumnWidth("Narrators", 100); set => setColumnWidth("Narrators", value); }
@@ -480,6 +480,7 @@ namespace LibationAvalonia.ViewModels
private void setColumnWidth(string columnName, DataGridLength width, [CallerMemberName] string propertyName = "")
{
if (DisablePersistColumnWidths) return;
var dictionary = Configuration.Instance.GridColumnsWidths;
var newValue = (int)width.DisplayValue;

View File

@@ -26,7 +26,7 @@
<DataGrid.Styles>
<Style Selector="DataGridColumnHeader">
<Setter Property="ContextMenu">
<ContextMenu Name="GridHeaderContextMenu" Opening="ContextMenu_ContextMenuOpening" Closed="ContextMenu_MenuClosed">
<ContextMenu Name="GridHeaderContextMenu" Opening="GridHeaderContextMenu_Opening" Closed="GridHeaderContextMenu_Closed">
<ContextMenu.Styles>
<Style Selector="MenuItem">
<Setter Property="Padding" Value="10,0,-10,0" />
@@ -51,6 +51,11 @@
</ContextMenu>
</Setter>
</Style>
<Style Selector="DataGridCell">
<Setter Property="ContextMenu">
<ContextMenu Opening="GridCellContextMenu_Opening" Opened="GridCellContextMenu_Opened"/>
</Setter>
</Style>
<Style Selector="DataGridCell > Panel">
<Setter Property="VerticalAlignment" Value="Stretch"/>
</Style>

View File

@@ -1,8 +1,6 @@
using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using DataLayer;
@@ -30,13 +28,31 @@ namespace LibationAvalonia.Views
public event EventHandler<LibraryBook[]>? ConvertToMp3Clicked;
public event EventHandler<LibraryBook>? TagsButtonClicked;
public static readonly StyledProperty<bool> DisableContextMenuProperty =
AvaloniaProperty.Register<GroupBox, bool>(nameof(DisableContextMenu));
public static readonly StyledProperty<bool> DisableColumnCustomizationProperty =
AvaloniaProperty.Register<GroupBox, bool>(nameof(DisableColumnCustomization));
public bool DisableContextMenu
{
get { return GetValue(DisableContextMenuProperty); }
set { SetValue(DisableContextMenuProperty, value); }
}
public bool DisableColumnCustomization
{
get { return GetValue(DisableColumnCustomizationProperty); }
set { SetValue(DisableColumnCustomizationProperty, value); }
}
private ProductsDisplayViewModel? _viewModel => DataContext as ProductsDisplayViewModel;
ImageDisplayDialog? imageDisplayDialog;
public ProductsDisplay()
{
InitializeComponent();
DataGridContextMenus.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded;
var cellSelector = Selectors.Is<DataGridCell>(null);
rowHeightStyle = new Style(_ => cellSelector);
@@ -91,6 +107,18 @@ namespace LibationAvalonia.Views
}
}
protected override void OnApplyTemplate(Avalonia.Controls.Primitives.TemplateAppliedEventArgs e)
{
ApplyDisableColumnCustimaziton();
base.OnApplyTemplate(e);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property == DisableColumnCustomizationProperty)
ApplyDisableColumnCustimaziton();
base.OnPropertyChanged(change);
}
private void ProductsDisplay_LoadingRow(object sender, DataGridRowEventArgs e)
{
if (e.Row.DataContext is LibraryBookEntry entry && entry.Liberate?.IsEpisode is true)
@@ -180,10 +208,19 @@ namespace LibationAvalonia.Views
#endregion
#region Cell Context Menu
public void ProductsGrid_CellContextMenuStripNeeded(object? sender, DataGridCellContextMenuStripNeededEventArgs args)
public void GridCellContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e)
{
var entries = args.GridEntries;
e.Cancel = DisableContextMenu;
}
//Use Opened instead of opening because the parent is not set yet in Opening
public void GridCellContextMenu_Opened(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (sender is not ContextMenu contextMenu ||
DataGridCellContextMenu<GridEntry>.Create(contextMenu) is not { } args)
return;
var entries = args.RowItems;
var ctx = new GridContextMenu(entries, '_');
if (App.MainWindow?.Clipboard is IClipboard clipboard)
@@ -206,8 +243,8 @@ namespace LibationAvalonia.Views
});
args.ContextMenuItems.Add(new Separator());
}
}
#region Liberate all Episodes (Single series only)
@@ -454,13 +491,13 @@ namespace LibationAvalonia.Views
var itemName = column.SortMemberPath;
if (itemName == nameof(GridEntry.Remove))
continue;
GridHeaderContextMenu.Items.Add(new MenuItem
{
Header = new CheckBox { Content = new TextBlock { Text = ((string)column.Header).Replace('\n', ' ') } },
Tag = column,
});
column.IsVisible = Configuration.Instance.GetColumnVisibility(itemName);
}
@@ -478,10 +515,19 @@ namespace LibationAvalonia.Views
}
}
public void ContextMenu_ContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
private void ApplyDisableColumnCustimaziton()
{
if (sender is not ContextMenu contextMenu)
_viewModel?.DisablePersistColumnWidths = DisableColumnCustomization;
productsGrid.CanUserReorderColumns = !DisableColumnCustomization;
}
public void GridHeaderContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (DisableContextMenu || sender is not ContextMenu contextMenu)
{
e.Cancel = true;
return;
}
foreach (var mi in contextMenu.Items.OfType<MenuItem>())
{
if (mi.Tag is DataGridColumn column && mi.Header is CheckBox cbox)
@@ -491,7 +537,7 @@ namespace LibationAvalonia.Views
}
}
public void ContextMenu_MenuClosed(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
public void GridHeaderContextMenu_Closed(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (sender is not ContextMenu contextMenu)
return;
@@ -518,6 +564,7 @@ namespace LibationAvalonia.Views
private void ProductsGrid_ColumnDisplayIndexChanged(object? sender, DataGridColumnEventArgs e)
{
if (DisableColumnCustomization) return;
var config = Configuration.Instance;
var dictionary = config.GridColumnsDisplayIndices;

View File

@@ -93,6 +93,12 @@ namespace LibationSearchEngine
}
}
public SearchEngine(string directory = null)
{
SearchEngineDirectory = directory
?? new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("SearchEngine").FullName;
}
/// <summary>Long running. Use await Task.Run(() => UpdateBook(productId))</summary>
public void UpdateBook(LibationContext context, string productId)
{
@@ -131,7 +137,7 @@ namespace LibationSearchEngine
public void UpdateTags(string productId, string tags) => updateAnalyzedField(productId, TAGS, tags);
// all fields are case-specific
private static void updateAnalyzedField(string productId, string fieldName, string newValue)
private void updateAnalyzedField(string productId, string fieldName, string newValue)
=> updateDocument(
productId,
d =>
@@ -170,7 +176,7 @@ namespace LibationSearchEngine
d.AddIndexRule(rating, book);
});
private static void updateDocument(string productId, Action<Document> action)
private void updateDocument(string productId, Action<Document> action)
{
var productTerm = new Term(_ID_, productId);
@@ -277,10 +283,10 @@ namespace LibationSearchEngine
}
#endregion
private static Directory getIndex() => FSDirectory.Open(SearchEngineDirectory);
private Directory getIndex() => FSDirectory.Open(SearchEngineDirectory);
// not customizable. don't move to config
private static string SearchEngineDirectory { get; }
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("SearchEngine").FullName;
//Defaults to "LibationFiles/SearchEngine, but can be overridden
//in constructor for use in TrashBinDialog search
private string SearchEngineDirectory { get; }
}
}

View File

@@ -45,16 +45,13 @@ namespace LibationUiBase.GridView
=> searchSet is null != otherSet is null ||
(searchSet is not null &&
otherSet is not null &&
searchSet.Intersect(otherSet).Count() != searchSet.Count);
searchSet.Intersect(otherSet).Count() != searchSet.Count);
[return: NotNullIfNotNull(nameof(searchString))]
public static HashSet<GridEntry>? FilterEntries(this IEnumerable<GridEntry> entries, string? searchString)
[return: NotNullIfNotNull(nameof(searchResultSet))]
public static HashSet<GridEntry>? FilterEntries(this IEnumerable<GridEntry> entries, LibationSearchEngine.SearchResultSet? searchResultSet)
{
if (string.IsNullOrEmpty(searchString))
if (searchResultSet is null)
return null;
var searchResultSet = SearchEngineCommands.Search(searchString);
var booksFilteredIn = entries.IntersectBy(searchResultSet.Docs.Select(d => d.ProductId), l => l.AudibleProductId);
//Find all series containing children that match the search criteria

View File

@@ -28,40 +28,35 @@
/// </summary>
private void InitializeComponent()
{
deletedCbl = new System.Windows.Forms.CheckedListBox();
label1 = new System.Windows.Forms.Label();
restoreBtn = new System.Windows.Forms.Button();
permanentlyDeleteBtn = new System.Windows.Forms.Button();
everythingCb = new System.Windows.Forms.CheckBox();
deletedCheckedLbl = new System.Windows.Forms.Label();
productsGrid1 = new LibationWinForms.GridView.ProductsGrid();
label2 = new System.Windows.Forms.Label();
textBox1 = new System.Windows.Forms.TextBox();
button1 = new System.Windows.Forms.Button();
audiblePlusCb = new System.Windows.Forms.CheckBox();
plusBookcSheckedLbl = new System.Windows.Forms.Label();
SuspendLayout();
//
// deletedCbl
//
deletedCbl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
deletedCbl.FormattingEnabled = true;
deletedCbl.Location = new System.Drawing.Point(12, 27);
deletedCbl.Name = "deletedCbl";
deletedCbl.Size = new System.Drawing.Size(776, 364);
deletedCbl.TabIndex = 3;
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
//
// label1
//
label1.AutoSize = true;
label1.Location = new System.Drawing.Point(12, 9);
label1.Name = "label1";
label1.Size = new System.Drawing.Size(388, 15);
label1.TabIndex = 4;
label1.TabIndex = 0;
label1.Text = "Check books you want to permanently delete from or restore to Libation";
//
// restoreBtn
//
restoreBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
restoreBtn.Location = new System.Drawing.Point(572, 398);
restoreBtn.Location = new System.Drawing.Point(572, 450);
restoreBtn.Name = "restoreBtn";
restoreBtn.Size = new System.Drawing.Size(75, 40);
restoreBtn.TabIndex = 5;
restoreBtn.TabIndex = 6;
restoreBtn.Text = "Restore";
restoreBtn.UseVisualStyleBackColor = true;
restoreBtn.Click += restoreBtn_Click;
@@ -69,10 +64,10 @@
// permanentlyDeleteBtn
//
permanentlyDeleteBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 398);
permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 450);
permanentlyDeleteBtn.Name = "permanentlyDeleteBtn";
permanentlyDeleteBtn.Size = new System.Drawing.Size(135, 40);
permanentlyDeleteBtn.TabIndex = 5;
permanentlyDeleteBtn.TabIndex = 7;
permanentlyDeleteBtn.Text = "Permanently Remove\r\nfrom Libation";
permanentlyDeleteBtn.UseVisualStyleBackColor = true;
permanentlyDeleteBtn.Click += permanentlyDeleteBtn_Click;
@@ -81,10 +76,11 @@
//
everythingCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
everythingCb.AutoSize = true;
everythingCb.Location = new System.Drawing.Point(12, 410);
everythingCb.Location = new System.Drawing.Point(12, 462);
everythingCb.Margin = new System.Windows.Forms.Padding(10, 3, 3, 3);
everythingCb.Name = "everythingCb";
everythingCb.Size = new System.Drawing.Size(82, 19);
everythingCb.TabIndex = 6;
everythingCb.TabIndex = 4;
everythingCb.Text = "Everything";
everythingCb.ThreeState = true;
everythingCb.UseVisualStyleBackColor = true;
@@ -94,23 +90,93 @@
//
deletedCheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
deletedCheckedLbl.AutoSize = true;
deletedCheckedLbl.Location = new System.Drawing.Point(126, 411);
deletedCheckedLbl.Location = new System.Drawing.Point(100, 463);
deletedCheckedLbl.Name = "deletedCheckedLbl";
deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
deletedCheckedLbl.TabIndex = 7;
deletedCheckedLbl.TabIndex = 0;
deletedCheckedLbl.Text = "Checked: {0} of {1}";
//
// productsGrid1
//
productsGrid1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
productsGrid1.AutoScroll = true;
productsGrid1.DisableColumnCustomization = true;
productsGrid1.DisableContextMenu = true;
productsGrid1.Location = new System.Drawing.Point(12, 62);
productsGrid1.Name = "productsGrid1";
productsGrid1.SearchEngine = null;
productsGrid1.Size = new System.Drawing.Size(776, 382);
productsGrid1.TabIndex = 3;
//
// label2
//
label2.AutoSize = true;
label2.Location = new System.Drawing.Point(12, 36);
label2.Name = "label2";
label2.Size = new System.Drawing.Size(123, 15);
label2.TabIndex = 0;
label2.Text = "Search Deleted Books:";
//
// textBox1
//
textBox1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
textBox1.Location = new System.Drawing.Point(141, 33);
textBox1.Name = "textBox1";
textBox1.Size = new System.Drawing.Size(574, 23);
textBox1.TabIndex = 1;
textBox1.KeyDown += textBox1_KeyDown;
//
// button1
//
button1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
button1.Location = new System.Drawing.Point(721, 33);
button1.Name = "button1";
button1.Size = new System.Drawing.Size(67, 23);
button1.TabIndex = 2;
button1.Text = "Filter";
button1.UseVisualStyleBackColor = true;
button1.Click += searchBtn_Click;
//
// audiblePlusCb
//
audiblePlusCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
audiblePlusCb.AutoSize = true;
audiblePlusCb.Location = new System.Drawing.Point(247, 462);
audiblePlusCb.Margin = new System.Windows.Forms.Padding(10, 3, 3, 3);
audiblePlusCb.Name = "audiblePlusCb";
audiblePlusCb.Size = new System.Drawing.Size(127, 19);
audiblePlusCb.TabIndex = 5;
audiblePlusCb.Text = "Audible Plus Books";
audiblePlusCb.ThreeState = true;
audiblePlusCb.UseVisualStyleBackColor = true;
audiblePlusCb.CheckStateChanged += audiblePlusCb_CheckStateChanged;
//
// plusBookcSheckedLbl
//
plusBookcSheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
plusBookcSheckedLbl.AutoSize = true;
plusBookcSheckedLbl.Location = new System.Drawing.Point(380, 463);
plusBookcSheckedLbl.Name = "plusBookcSheckedLbl";
plusBookcSheckedLbl.Size = new System.Drawing.Size(104, 15);
plusBookcSheckedLbl.TabIndex = 0;
plusBookcSheckedLbl.Text = "Checked: {0} of {1}";
//
// TrashBinDialog
//
AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
ClientSize = new System.Drawing.Size(800, 450);
ClientSize = new System.Drawing.Size(800, 502);
Controls.Add(plusBookcSheckedLbl);
Controls.Add(button1);
Controls.Add(textBox1);
Controls.Add(label2);
Controls.Add(productsGrid1);
Controls.Add(deletedCheckedLbl);
Controls.Add(audiblePlusCb);
Controls.Add(everythingCb);
Controls.Add(permanentlyDeleteBtn);
Controls.Add(restoreBtn);
Controls.Add(label1);
Controls.Add(deletedCbl);
Name = "TrashBinDialog";
Text = "Trash Bin";
ResumeLayout(false);
@@ -118,12 +184,16 @@
}
#endregion
private System.Windows.Forms.CheckedListBox deletedCbl;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.Button restoreBtn;
private System.Windows.Forms.Button permanentlyDeleteBtn;
private System.Windows.Forms.CheckBox everythingCb;
private System.Windows.Forms.Label deletedCheckedLbl;
private GridView.ProductsGrid productsGrid1;
private System.Windows.Forms.Label label2;
private System.Windows.Forms.TextBox textBox1;
private System.Windows.Forms.Button button1;
private System.Windows.Forms.CheckBox audiblePlusCb;
private System.Windows.Forms.Label plusBookcSheckedLbl;
}
}

View File

@@ -1,18 +1,21 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core.Collections.Generic;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using DataLayer;
using LibationFileManager;
using System.Collections;
#nullable enable
namespace LibationWinForms.Dialogs
{
public partial class TrashBinDialog : Form
{
private readonly string deletedCheckedTemplate;
private string lastGoodFilter = "";
private TempSearchEngine SearchEngine { get; } = new TempSearchEngine();
public TrashBinDialog()
{
InitializeComponent();
@@ -21,29 +24,67 @@ namespace LibationWinForms.Dialogs
this.RestoreSizeAndLocation(Configuration.Instance);
this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
deletedCheckedTemplate = deletedCheckedLbl.Text;
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
foreach (var lb in deletedBooks)
deletedCbl.Items.Add(lb);
setLabel();
deletedCheckedLbl.Text = "";
plusBookcSheckedLbl.Text = "";
productsGrid1.SearchEngine = SearchEngine;
productsGrid1.RemovableCountChanged += (_, _) => UpdateCounts();
productsGrid1.VisibleCountChanged += (_, _) => UpdateCounts();
Load += TrashBinDialog_Load;
}
private void deletedCbl_ItemCheck(object sender, ItemCheckEventArgs e)
private IEnumerable<LibraryBook> GetCheckedBooks() => productsGrid1.GetVisibleGridEntries().Where(i => i.Remove is true).Select(i => i.LibraryBook);
private async void TrashBinDialog_Load(object? sender, EventArgs e)
{
// CheckedItems.Count is not updated until after the event fires
setLabel(e.NewValue);
productsGrid1.RemoveColumnVisible = true;
await InitAsync();
}
private void UpdateCounts()
{
var visible = productsGrid1.GetVisibleGridEntries().ToArray();
var plusVisibleCount = visible.Count(e => e.LibraryBook.IsAudiblePlus);
var checkedCount = visible.Count(e => e.Remove is true);
var plusCheckedCount = visible.Count(e => e.LibraryBook.IsAudiblePlus && e.Remove is true);
deletedCheckedLbl.Text = $"Checked: {checkedCount} of {visible.Length}";
plusBookcSheckedLbl.Text = $"Checked: {plusCheckedCount} of {plusVisibleCount}";
everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged;
everythingCb.CheckState = checkedCount == 0 || visible.Length == 0 ? CheckState.Unchecked
: checkedCount == visible.Length ? CheckState.Checked
: CheckState.Indeterminate;
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
audiblePlusCb.CheckStateChanged -= audiblePlusCb_CheckStateChanged;
audiblePlusCb.CheckState = plusCheckedCount == 0 || plusVisibleCount == 0 ? CheckState.Unchecked
: plusCheckedCount == plusVisibleCount ? CheckState.Checked
: CheckState.Indeterminate;
audiblePlusCb.CheckStateChanged += audiblePlusCb_CheckStateChanged;
}
private async Task InitAsync()
{
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
SearchEngine.ReindexSearchEngine(deletedBooks);
await productsGrid1.BindToGridAsync(deletedBooks);
}
private void Reload()
{
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
SearchEngine.ReindexSearchEngine(deletedBooks);
productsGrid1.UpdateGrid(deletedBooks);
}
private async void permanentlyDeleteBtn_Click(object sender, EventArgs e)
{
setControlsEnabled(false);
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
removeFromCheckList(removed);
await removed.PermanentlyDeleteBooksAsync();
var qtyChanges = await GetCheckedBooks().PermanentlyDeleteBooksAsync();
if (qtyChanges > 0)
Reload();
setControlsEnabled(true);
}
@@ -52,65 +93,70 @@ namespace LibationWinForms.Dialogs
{
setControlsEnabled(false);
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
removeFromCheckList(removed);
await removed.RestoreBooksAsync();
var qtyChanges = await GetCheckedBooks().RestoreBooksAsync();
if (qtyChanges > 0)
Reload();
setControlsEnabled(true);
}
private void removeFromCheckList(IEnumerable objects)
{
foreach (var o in objects)
deletedCbl.Items.Remove(o);
deletedCbl.Refresh();
setLabel();
}
private void setControlsEnabled(bool enabled)
=> restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = deletedCbl.Enabled = everythingCb.Enabled = enabled;
=> Invoke(() => productsGrid1.Enabled = restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = everythingCb.Enabled = enabled);
private void everythingCb_CheckStateChanged(object sender, EventArgs e)
private void textBox1_KeyDown(object sender, KeyEventArgs e)
{
if (everythingCb.CheckState is CheckState.Indeterminate)
{
everythingCb.CheckState = CheckState.Unchecked;
return;
}
deletedCbl.ItemCheck -= deletedCbl_ItemCheck;
for (var i = 0; i < deletedCbl.Items.Count; i++)
deletedCbl.SetItemChecked(i, everythingCb.CheckState is CheckState.Checked);
setLabel();
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
if (e.KeyCode == Keys.Enter)
searchBtn_Click(sender, e);
}
private void setLabel(CheckState? checkedState = null)
private void searchBtn_Click(object sender, EventArgs e)
{
var pre = deletedCbl.CheckedItems.Count;
int count = checkedState switch
try
{
CheckState.Checked => pre + 1,
CheckState.Unchecked => pre - 1,
_ => pre,
};
productsGrid1.Filter(textBox1.Text);
lastGoodFilter = textBox1.Text;
}
catch
{
productsGrid1.Filter(lastGoodFilter);
}
}
everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged;
private void audiblePlusCb_CheckStateChanged(object? sender, EventArgs e)
{
switch (audiblePlusCb.CheckState)
{
case CheckState.Checked:
SetVisibleChecked(e => e.IsAudiblePlus, isChecked: true);
break;
case CheckState.Unchecked:
SetVisibleChecked(e => e.IsAudiblePlus, isChecked: false);
break;
default:
audiblePlusCb.CheckState = CheckState.Unchecked;
break;
}
}
private void everythingCb_CheckStateChanged(object? sender, EventArgs e)
{
switch (everythingCb.CheckState)
{
case CheckState.Checked:
SetVisibleChecked(_ => true, isChecked: true);
break;
case CheckState.Unchecked:
SetVisibleChecked(_ => true, isChecked: false);
break;
default:
everythingCb.CheckState = CheckState.Unchecked;
break;
}
}
everythingCb.CheckState
= count > 0 && count == deletedCbl.Items.Count ? CheckState.Checked
: count == 0 ? CheckState.Unchecked
: CheckState.Indeterminate;
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
deletedCheckedLbl.Text = string.Format(deletedCheckedTemplate, count, deletedCbl.Items.Count);
public void SetVisibleChecked(Func<LibraryBook, bool> predicate, bool isChecked)
{
productsGrid1.GetVisibleGridEntries().Where(e => predicate(e.LibraryBook)).ForEach(i => i.Remove = isChecked);
UpdateCounts();
}
}
}

View File

@@ -1,4 +1,64 @@
<root>
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<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">

View File

@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
#nullable enable
namespace LibationWinForms.GridView
{
/*
@@ -43,8 +44,10 @@ namespace LibationWinForms.GridView
.OfType<LibraryBookEntry>()
.Union(Items.OfType<LibraryBookEntry>());
public ISearchEngine? SearchEngine { get; set; }
public bool SupportsFiltering => true;
public string Filter
public string? Filter
{
get => FilterString;
set
@@ -54,7 +57,8 @@ namespace LibationWinForms.GridView
if (Items.Count + FilterRemoved.Count == 0)
return;
FilteredInGridEntries = AllItems().FilterEntries(FilterString);
var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString);
FilteredInGridEntries = AllItems().FilterEntries(searchResultSet);
refreshEntries();
}
}
@@ -63,16 +67,16 @@ namespace LibationWinForms.GridView
protected override bool SupportsSortingCore => true;
protected override bool SupportsSearchingCore => true;
protected override bool IsSortedCore => isSorted;
protected override PropertyDescriptor SortPropertyCore => propertyDescriptor;
protected override PropertyDescriptor? SortPropertyCore => propertyDescriptor;
protected override ListSortDirection SortDirectionCore => Comparer.SortOrder;
/// <summary> Items that were removed from the base list due to filtering </summary>
private readonly List<GridEntry> FilterRemoved = new();
private string FilterString;
private string? FilterString;
private bool isSorted;
private PropertyDescriptor propertyDescriptor;
private PropertyDescriptor? propertyDescriptor;
/// <summary> All GridEntries present in the current filter set. If null, no filter is applied and all entries are filtered in.(This was a performance choice)</summary>
private HashSet<GridEntry> FilteredInGridEntries;
private HashSet<GridEntry>? FilteredInGridEntries;
#region Unused - Advanced Filtering
public bool SupportsAdvancedSorting => false;
@@ -128,7 +132,7 @@ namespace LibationWinForms.GridView
//(except for episodes that are collapsed)
foreach (var addBack in addBackEntries)
{
if (addBack is LibraryBookEntry lbe && lbe.Parent is SeriesEntry se && !se.Liberate.Expanded)
if (addBack is LibraryBookEntry lbe && lbe.Parent is SeriesEntry se && se.Liberate?.Expanded is not true)
continue;
FilterRemoved.Remove(addBack);
@@ -137,9 +141,10 @@ namespace LibationWinForms.GridView
}
}
private void SearchEngineCommands_SearchEngineUpdated(object sender, EventArgs e)
private void SearchEngineCommands_SearchEngineUpdated(object? sender, EventArgs e)
{
var filterResults = AllItems().FilterEntries(FilterString);
var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString);
var filterResults = AllItems().FilterEntries(searchResultSet);
if (FilteredInGridEntries.SearchSetsDiffer(filterResults))
{
@@ -168,7 +173,7 @@ namespace LibationWinForms.GridView
base.Remove(episode);
}
sEntry.Liberate.Expanded = false;
sEntry.Liberate?.Expanded = false;
}
public void ExpandItem(SeriesEntry sEntry)
@@ -183,7 +188,7 @@ namespace LibationWinForms.GridView
InsertItem(++sindex, episode);
}
}
sEntry.Liberate.Expanded = true;
sEntry.Liberate?.Expanded = true;
}
public void RemoveFilter()
@@ -216,7 +221,7 @@ namespace LibationWinForms.GridView
itemsList.AddRange(sortedItems);
}
private void GridEntryBindingList_ListChanged(object sender, ListChangedEventArgs e)
private void GridEntryBindingList_ListChanged(object? sender, ListChangedEventArgs e)
{
if (e.ListChangedType == ListChangedType.ItemChanged && IsSortedCore && e.PropertyDescriptor == SortPropertyCore)
refreshEntries();

View File

@@ -32,6 +32,7 @@ namespace LibationWinForms.GridView
public ProductsDisplay()
{
InitializeComponent();
productsGrid.SearchEngine = MainSearchEngine.Instance;
}
#region Button controls
@@ -432,7 +433,7 @@ namespace LibationWinForms.GridView
#endregion
internal List<LibraryBook> GetVisible() => productsGrid.GetVisibleBooks().ToList();
internal List<LibraryBook> GetVisible() => productsGrid.GetVisibleBookEntries().ToList();
private void productsGrid_VisibleCountChanged(object sender, int count)
{

View File

@@ -1,4 +1,5 @@
using DataLayer;
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
using Dinah.Core.WindowsDesktop.Forms;
@@ -6,6 +7,7 @@ using LibationFileManager;
using LibationUiBase.GridView;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
@@ -22,6 +24,23 @@ namespace LibationWinForms.GridView
public partial class ProductsGrid : UserControl
{
[DefaultValue(false)]
[Category("Behavior")]
[Description("Disable the grid context menu")]
public bool DisableContextMenu { get; set; }
[DefaultValue(false)]
[Category("Behavior")]
[Description("Disable grid column reordering and don't persist width changes")]
public bool DisableColumnCustomization
{
get => field;
set
{
field = value;
gridEntryDataGridView.AllowUserToOrderColumns = !value;
}
}
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int>? VisibleCountChanged;
public event LibraryBookEntryClickedEventHandler? LiberateClicked;
@@ -33,13 +52,17 @@ namespace LibationWinForms.GridView
public event ProductsGridCellContextMenuStripNeededEventHandler? LiberateContextMenuStripNeeded;
private GridEntryBindingList? bindingList;
internal IEnumerable<LibraryBook> GetVisibleBooks()
=> bindingList
?.GetFilteredInItems()
.Select(lbe => lbe.LibraryBook) ?? Enumerable.Empty<LibraryBook>();
internal IEnumerable<LibraryBook> GetVisibleBookEntries()
=> GetVisibleGridEntries().Select(lbe => lbe.LibraryBook);
public IEnumerable<LibraryBookEntry> GetVisibleGridEntries()
=> bindingList?.GetFilteredInItems().OfType<LibraryBookEntry>() ?? [];
internal IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> bindingList?.AllItems().BookEntries() ?? Enumerable.Empty<LibraryBookEntry>();
public ISearchEngine? SearchEngine { get => field; set { field = value; bindingList?.SearchEngine = value; } }
public ProductsGrid()
{
InitializeComponent();
@@ -47,19 +70,7 @@ namespace LibationWinForms.GridView
gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s);
gridEntryDataGridView.CellContextMenuStripNeeded += GridEntryDataGridView_CellContextMenuStripNeeded;
removeGVColumn.Frozen = false;
defaultFont = gridEntryDataGridView.DefaultCellStyle.Font ?? gridEntryDataGridView.Font;
setGridFontScale(Configuration.Instance.GridFontScaleFactor);
setGridScale(Configuration.Instance.GridScaleFactor);
Configuration.Instance.PropertyChanged += Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged;
gridEntryDataGridView.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled;
gridEntryDataGridView.Disposed += (_, _) =>
{
Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged;
};
defaultFont = gridEntryDataGridView.DefaultCellStyle.Font ?? gridEntryDataGridView.Font;
}
#region Scaling
@@ -120,7 +131,7 @@ namespace LibationWinForms.GridView
private void GridEntryDataGridView_CellContextMenuStripNeeded(object? sender, DataGridViewCellContextMenuStripNeededEventArgs e)
{
// header
if (e.RowIndex < 0 || sender is not DataGridView dgv)
if (DisableContextMenu || e.RowIndex < 0 || sender is not DataGridView dgv)
return;
e.ContextMenuStrip = new ContextMenuStrip();
@@ -313,7 +324,7 @@ namespace LibationWinForms.GridView
}
System.Threading.SynchronizationContext.SetSynchronizationContext(null);
bindingList = new GridEntryBindingList(geList);
bindingList = new GridEntryBindingList(geList) { SearchEngine = SearchEngine };
bindingList.CollapseAll();
//The syncBindingSource ensures that the IGridEntry list is added on the UI thread
@@ -381,7 +392,8 @@ namespace LibationWinForms.GridView
RemoveBooks(removedBooks);
gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow;
if (topRow >= 0 && topRow < gridEntryDataGridView.RowCount)
gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow;
}
public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks)
@@ -505,8 +517,21 @@ namespace LibationWinForms.GridView
private void ProductsGrid_Load(object sender, EventArgs e)
{
//https://stackoverflow.com/a/4498512/3335599
if (System.ComponentModel.LicenseManager.UsageMode == System.ComponentModel.LicenseUsageMode.Designtime) return;
//DesignMode is not set in constructor
if (DesignMode)
return;
setGridFontScale(Configuration.Instance.GridFontScaleFactor);
setGridScale(Configuration.Instance.GridScaleFactor);
Configuration.Instance.PropertyChanged += Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged;
gridEntryDataGridView.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled;
gridEntryDataGridView.Disposed += (_, _) =>
{
Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged;
};
gridEntryDataGridView.ColumnWidthChanged += gridEntryDataGridView_ColumnWidthChanged;
gridEntryDataGridView.ColumnDisplayIndexChanged += gridEntryDataGridView_ColumnDisplayIndexChanged;
@@ -523,6 +548,8 @@ namespace LibationWinForms.GridView
foreach (DataGridViewColumn column in gridEntryDataGridView.Columns)
{
if (column == removeGVColumn)
continue;
var itemName = column.DataPropertyName;
var visible = config.GetColumnVisibility(itemName);
@@ -596,6 +623,7 @@ namespace LibationWinForms.GridView
private void gridEntryDataGridView_ColumnDisplayIndexChanged(object? sender, DataGridViewColumnEventArgs e)
{
if (DisableColumnCustomization) return;
var config = Configuration.Instance;
var dictionary = config.GridColumnsDisplayIndices;
@@ -613,6 +641,7 @@ namespace LibationWinForms.GridView
private void gridEntryDataGridView_ColumnWidthChanged(object? sender, DataGridViewColumnEventArgs e)
{
if (DisableColumnCustomization) return;
var config = Configuration.Instance;
var dictionary = config.GridColumnsWidths;