Libation/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs
Michael Bucari-Tovo bff9b67b72 Remove GridEntry derrived types and interfaces
Use existing BaseUtil.LoadImage delegate, obviating need for derrived classes to load images

Since GridEntry types are no longer generic, interfaces are unnecessary and deleted.
2025-07-21 10:47:10 -06:00

506 lines
18 KiB
C#

using ApplicationServices;
using AudibleUtilities;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
using Dinah.Core.Collections.Generic;
using LibationFileManager;
using LibationUiBase.Forms;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public class ProductsDisplayViewModel : ViewModelBase
{
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int>? VisibleCountChanged;
public event EventHandler<int>? RemovableCountChanged;
/// <summary>Backing list of all grid entries</summary>
private readonly AvaloniaList<GridEntry> SOURCE = new();
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
private HashSet<GridEntry>? FilteredInGridEntries;
public string? FilterString { get; private set; }
private DataGridCollectionView? _gridEntries;
public DataGridCollectionView? GridEntries
{
get => _gridEntries;
private set => this.RaiseAndSetIfChanged(ref _gridEntries, value);
}
private bool _removeColumnVisible;
public bool RemoveColumnVisible { get => _removeColumnVisible; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisible, value); }
public List<LibraryBook> GetVisibleBookEntries()
=> FilteredInGridEntries?
.OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList()
?? SOURCE
.OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList();
private IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> SOURCE
.BookEntries();
public ProductsDisplayViewModel()
{
SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated;
VisibleCountChanged?.Invoke(this, 0);
}
private static readonly System.Reflection.MethodInfo? SetFlagsMethod;
/// <summary>
/// Tells the <see cref="DataGridCollectionView"/> whether it should process changes to the underlying collection
/// </summary>
/// <remarks> DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged = 4</remarks>
private void SetShouldProcessCollectionChanged(bool flagSet)
=> SetFlagsMethod?.Invoke(GridEntries, new object[] { 4, flagSet });
static ProductsDisplayViewModel()
{
/*
* When a book is removed from the library, SearchEngineUpdated is fired before LibrarySizeChanged, so
* the book is removed from the filtered set and the grid is refreshed before RemoveBooks() is ever
* called.
*
* To remove an item from DataGridCollectionView, it must be be in the current filtered view. If it's
* not and you try to remove the book from the source list, the source will fire NotifyCollectionChanged
* on an invalid item and the DataGridCollectionView will throw an exception. There are two ways to
* remove an item that is filtered out of the DataGridCollectionView:
*
* (1) Re-add the item to the filtered-in list and refresh the grid so DataGridCollectionView knows
* that the item is present. This causes the whole grid to flicker to refresh twice in rapid
* succession, which is undesirable.
*
* (2) Remove it from the underlying collection and suppress NotifyCollectionChanged. This is the
* method used. Steps to complete a removal using this method:
*
* (a) Set DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged to false.
* (b) Remove the item from the source list. The source will fire NotifyCollectionChanged, but the
* DataGridCollectionView will ignore it.
* (c) Reset the flag to true.
*/
SetFlagsMethod =
typeof(DataGridCollectionView)
.GetMethod("SetFlag", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
}
#region Display Functions
internal async Task BindToGridAsync(List<LibraryBook> dbBooks)
{
if (dbBooks == null)
throw new ArgumentNullException(nameof(dbBooks));
//Get the UI thread's synchronization context and set it on the current thread to ensure
//it's available for GetAllProductsAsync and GetAllSeriesEntriesAsync
var sc = await Dispatcher.UIThread.InvokeAsync(() => AvaloniaSynchronizationContext.Current);
AvaloniaSynchronizationContext.SetSynchronizationContext(sc);
var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks);
var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks);
//Add all IGridEntries to the SOURCE list. Note that SOURCE has not yet been linked to the UI via
//the GridEntries property, so adding items to SOURCE will not trigger any refreshes or UI action.
//This this can be done on any thread.
SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null)));
//Add all children beneath their parent
foreach (var series in seriesEntries)
{
var seriesIndex = SOURCE.IndexOf(series);
foreach (var child in series.Children)
SOURCE.Insert(++seriesIndex, child);
}
//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);
// Adding SOURCE to the DataGridViewCollection _after_ building the SOURCE list
//Saves ~500 ms on a library of ~4500 books.
//Perform on UI thread for safety, but at this time, merely setting the DataGridCollectionView
//does not trigger UI actions in the way that modifying the list after it's been linked does.
await Dispatcher.UIThread.InvokeAsync(() =>
{
GridEntries = new(SOURCE) { Filter = CollectionFilter };
GridEntries.CollectionChanged += GridEntries_CollectionChanged;
});
GridEntries_CollectionChanged();
}
private void GridEntries_CollectionChanged(object? sender = null, EventArgs? e = null)
{
var count
= FilteredInGridEntries?.OfType<LibraryBookEntry>().Count()
?? SOURCE.OfType<LibraryBookEntry>().Count();
VisibleCountChanged?.Invoke(this, count);
}
/// <summary>
/// Call when there's been a change to the library
/// </summary>
internal async Task UpdateGridAsync(List<LibraryBook> dbBooks)
{
if (dbBooks == null)
throw new ArgumentNullException(nameof(dbBooks));
if (GridEntries == null)
throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(UpdateGridAsync)}");
//CollectionChanged fires for every book added, and the handler invokes
//VisibleCountChanged which triggers Libation to re-count all books.
GridEntries.CollectionChanged -= GridEntries_CollectionChanged;
#region Add new or update existing grid entries
//Add absent entries to grid, or update existing entry
var allEntries = SOURCE.BookEntries().ToDictionarySafe(b => b.AudibleProductId);
var seriesEntries = SOURCE.SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet();
await Dispatcher.UIThread.InvokeAsync(() =>
{
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null;
if (libraryBook.Book.IsProduct())
UpsertBook(libraryBook, existingEntry);
else if (parentedEpisodes.Contains(libraryBook))
//Only try to add or update is this LibraryBook is a know child of a parent
UpsertEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
});
#endregion
#region Remove entries no longer in the library
//Rapid successive book removals will cause changes to SOURCE after the update has
//begun but before it has completed, so perform all updates on a copy of the list.
var sourceSnapshot = SOURCE.ToList();
// remove deleted from grid.
// note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this
var removedBooks =
sourceSnapshot
.BookEntries()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode))
removed.Parent.RemoveChild(removed);
//Remove series that have no children
var removedSeries = sourceSnapshot.EmptySeries();
await Dispatcher.UIThread.InvokeAsync(() => RemoveBooks(removedBooks, removedSeries));
#endregion
await Filter(FilterString);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
//Resubscribe after all changes to the list have been made
GridEntries.CollectionChanged += GridEntries_CollectionChanged;
GridEntries_CollectionChanged();
}
private void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks, IEnumerable<SeriesEntry> removedSeries)
{
foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries).Where(b => b is not null).ToList())
{
if (GridEntries?.PassesFilter(removed) ?? false)
GridEntries.Remove(removed);
else
{
SetShouldProcessCollectionChanged(false);
SOURCE.Remove(removed);
SetShouldProcessCollectionChanged(true);
}
}
}
private void UpsertBook(LibraryBook book, LibraryBookEntry? existingBookEntry)
{
if (existingBookEntry is null)
// Add the new product to top
SOURCE.Insert(0, new LibraryBookEntry(book));
else
// update existing
existingBookEntry.UpdateLibraryBook(book);
}
private void UpsertEpisode(LibraryBook episodeBook, LibraryBookEntry? existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (existingEpisodeEntry is null)
{
LibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
if (seriesEntry is null)
{
//Series doesn't exist yet, so create and add it
var seriesBook = dbBooks.FindSeriesParent(episodeBook);
if (seriesBook is null)
{
//This is only possible if the user's db has some malformed
//entries from earlier Libation releases that could not be
//automatically fixed. Log, but don't throw.
Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames());
return;
}
seriesEntry = new SeriesEntry(seriesBook, episodeBook);
seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0];
seriesEntry.Liberate.Expanded = true;
SOURCE.Insert(0, seriesEntry);
}
else
{
//Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateLibraryBook(seriesBook);
}
//Add episode to the grid beneath the parent
int seriesIndex = SOURCE.IndexOf(seriesEntry);
int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry);
SOURCE.Insert(seriesIndex + 1 + episodeIndex, episodeEntry);
}
else
existingEpisodeEntry.UpdateLibraryBook(episodeBook);
}
private async Task refreshGrid()
{
if (GridEntries != null)
{
if (GridEntries.IsEditingItem)
await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit);
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
}
}
public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry)
{
seriesEntry.Liberate.Expanded = !seriesEntry.Liberate.Expanded;
await refreshGrid();
}
#endregion
#region Filtering
public async Task Filter(string? searchString)
{
FilterString = searchString;
if (SOURCE.Count == 0)
return;
FilteredInGridEntries = SOURCE.FilterEntries(searchString);
await refreshGrid();
}
private bool CollectionFilter(object item)
{
if (item is LibraryBookEntry lbe
&& lbe.Liberate.IsEpisode
&& lbe.Parent?.Liberate?.Expanded != true)
return false;
if (FilteredInGridEntries is null) return true;
return FilteredInGridEntries.Contains(item);
}
private async void SearchEngineCommands_SearchEngineUpdated(object? sender, EventArgs? e)
{
var filterResults = SOURCE.FilterEntries(FilterString);
if (FilteredInGridEntries.SearchSetsDiffer(filterResults))
{
FilteredInGridEntries = filterResults;
await refreshGrid();
}
}
#endregion
#region Scan and Remove Books
public void DoneRemovingBooks()
{
foreach (var item in SOURCE)
item.PropertyChanged -= GridEntry_PropertyChanged;
RemoveColumnVisible = false;
}
public async Task RemoveCheckedBooksAsync()
{
var selectedBooks = GetAllBookEntries().Where(lbe => lbe.Remove == true).ToList();
if (selectedBooks.Count == 0)
return;
var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList();
var result = await MessageBox.ShowConfirmationDialog(
null,
booksToRemove,
// do not use `$` string interpolation. See impl.
"Are you sure you want to remove {0} from Libation's library?",
"Remove books from Libation?");
if (result != DialogResult.Yes)
return;
foreach (var book in selectedBooks)
book.PropertyChanged -= GridEntry_PropertyChanged;
void BindingList_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs? e)
{
if (e?.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
return;
//After DisplayBooks() re-creates the list,
//re-subscribe to all items' PropertyChanged events.
foreach (var b in GetAllBookEntries())
b.PropertyChanged += GridEntry_PropertyChanged;
GridEntries.CollectionChanged -= BindingList_CollectionChanged;
}
if (GridEntries != null)
GridEntries.CollectionChanged += BindingList_CollectionChanged;
//The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(),
//so there's no need to remove books from the grid display here.
await booksToRemove.RemoveBooksAsync();
RemovableCountChanged?.Invoke(this, 0);
}
public async Task ScanAndRemoveBooksAsync(params Account[] accounts)
{
foreach (var item in SOURCE)
{
item.Remove = false;
item.PropertyChanged += GridEntry_PropertyChanged;
}
RemoveColumnVisible = true;
RemovableCountChanged?.Invoke(this, 0);
try
{
if (accounts is null || accounts.Length == 0)
return;
var allBooks = GetAllBookEntries();
var lib = allBooks
.Select(lbe => lbe.LibraryBook)
.Where(lb => !lb.Book.HasLiberated());
var removedBooks = await LibraryCommands.FindInactiveBooks(lib, accounts);
var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList();
foreach (var r in removable)
r.Remove = true;
}
catch (OperationCanceledException)
{
Serilog.Log.Information("Audible login attempt cancelled by user");
}
catch (Exception ex)
{
await MessageBox.ShowAdminAlert(
null,
"Error scanning library. You may still manually select books to remove from Libation's library.",
"Error scanning library",
ex);
}
}
private void GridEntry_PropertyChanged(object? sender, PropertyChangedEventArgs? e)
{
if (e?.PropertyName == nameof(GridEntry.Remove) && sender is LibraryBookEntry)
{
int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true);
RemovableCountChanged?.Invoke(this, removeCount);
}
}
#endregion
#region Column Widths
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); }
public DataGridLength LengthWidth { get => getColumnWidth("Length", 80); set => setColumnWidth("Length", value); }
public DataGridLength SeriesWidth { get => getColumnWidth("Series", 100); set => setColumnWidth("Series", value); }
public DataGridLength SeriesOrderWidth { get => getColumnWidth("SeriesOrder", 60); set => setColumnWidth("SeriesOrder", value); }
public DataGridLength DescriptionWidth { get => getColumnWidth("Description", 100); set => setColumnWidth("Description", value); }
public DataGridLength CategoryWidth { get => getColumnWidth("Category", 100); set => setColumnWidth("Category", value); }
public DataGridLength ProductRatingWidth { get => getColumnWidth("ProductRating", 95); set => setColumnWidth("ProductRating", value); }
public DataGridLength PurchaseDateWidth { get => getColumnWidth("PurchaseDate", 75); set => setColumnWidth("PurchaseDate", value); }
public DataGridLength MyRatingWidth { get => getColumnWidth("MyRating", 95); set => setColumnWidth("MyRating", value); }
public DataGridLength MiscWidth { get => getColumnWidth("Misc", 140); set => setColumnWidth("Misc", value); }
public DataGridLength LastDownloadWidth { get => getColumnWidth("LastDownload", 100); set => setColumnWidth("LastDownload", value); }
public DataGridLength BookTagsWidth { get => getColumnWidth("BookTags", 100); set => setColumnWidth("BookTags", value); }
private static DataGridLength getColumnWidth(string columnName, double defaultWidth)
=> Configuration.Instance.GridColumnsWidths.TryGetValue(columnName, out var val)
? new DataGridLength(val)
: new DataGridLength(defaultWidth);
private void setColumnWidth(string columnName, DataGridLength width, [CallerMemberName] string propertyName = "")
{
var dictionary = Configuration.Instance.GridColumnsWidths;
var newValue = (int)width.DisplayValue;
var valueSame = dictionary.TryGetValue(columnName, out var val) && val == newValue;
dictionary[columnName] = newValue;
if (!valueSame)
{
Configuration.Instance.GridColumnsWidths = dictionary;
this.RaisePropertyChanged(propertyName);
}
}
#endregion
}
}