From fe55b90ee3e953ddce7330f123b82bf8c3d62620 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Wed, 1 Mar 2023 22:14:08 -0700 Subject: [PATCH] Fix rmcrackan/Libation#511 --- .../LibationAvalonia/ViewModels/GridEntry.cs | 33 ++- .../ViewModels/LibraryBookEntry.cs | 17 +- .../ViewModels/ProductsDisplayViewModel.cs | 243 ++++++++++++++---- .../ViewModels/QueryExtensions.cs | 6 + .../ViewModels/SeriesEntry.cs | 29 ++- .../Views/MainWindow.axaml.cs | 4 +- .../Views/ProductsDisplay.axaml.cs | 2 +- 7 files changed, 260 insertions(+), 74 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/GridEntry.cs b/Source/LibationAvalonia/ViewModels/GridEntry.cs index cfe0180c..f9fd3fd6 100644 --- a/Source/LibationAvalonia/ViewModels/GridEntry.cs +++ b/Source/LibationAvalonia/ViewModels/GridEntry.cs @@ -35,18 +35,29 @@ namespace LibationAvalonia.ViewModels #region Model properties exposed to the view private Avalonia.Media.Imaging.Bitmap _cover; - public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set { this.RaiseAndSetIfChanged(ref _cover, value); } } - public string PurchaseDate { get; protected set; } - public string Series { get; protected set; } - public string Title { get; protected set; } - public string Length { get; protected set; } - public string Authors { get; protected set; } - public string Narrators { get; protected set; } - public string Category { get; protected set; } - public string Misc { get; protected set; } - public string Description { get; protected set; } - public Rating ProductRating { get; protected set; } + private string _purchasedate; + private string _series; + private string _title; + private string _length; + private string _authors; + private string _narrators; + private string _category; + private string _misc; + private string _description; + private Rating _productrating; protected Rating _myRating; + + public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set => this.RaiseAndSetIfChanged(ref _cover, value); } + public string PurchaseDate { get => _purchasedate; protected set => this.RaiseAndSetIfChanged(ref _purchasedate, value); } + public string Series { get => _series; protected set => this.RaiseAndSetIfChanged(ref _series, value); } + public string Title { get => _title; protected set => this.RaiseAndSetIfChanged(ref _title, value); } + public string Length { get => _length; protected set => this.RaiseAndSetIfChanged(ref _length, value); } + public string Authors { get => _authors; protected set => this.RaiseAndSetIfChanged(ref _authors, value); } + public string Narrators { get => _narrators; protected set => this.RaiseAndSetIfChanged(ref _narrators, value); } + public string Category { get => _category; protected set => this.RaiseAndSetIfChanged(ref _category, value); } + public string Misc { get => _misc; protected set => this.RaiseAndSetIfChanged(ref _misc, value); } + public string Description { get => _description; protected set => this.RaiseAndSetIfChanged(ref _description, value); } + public Rating ProductRating { get => _productrating; protected set => this.RaiseAndSetIfChanged(ref _productrating, value); } public Rating MyRating { get => _myRating; diff --git a/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs b/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs index e00c9461..b28f8704 100644 --- a/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs +++ b/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs @@ -58,8 +58,22 @@ namespace LibationAvalonia.ViewModels public LibraryBookEntry(LibraryBook libraryBook) { - LibraryBook = libraryBook; + setLibraryBook(libraryBook); LoadCover(); + } + + public void UpdateLibraryBook(LibraryBook libraryBook) + { + if (AudibleProductId != libraryBook.Book.AudibleProductId) + throw new Exception("Invalid grid entry update. IDs must match"); + + UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; + setLibraryBook(libraryBook); + } + + private void setLibraryBook(LibraryBook libraryBook) + { + LibraryBook = libraryBook; Title = Book.Title; Series = Book.SeriesNames(); @@ -77,6 +91,7 @@ namespace LibationAvalonia.ViewModels Description = TrimTextToWord(LongDescription, 62); SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; + this.RaisePropertyChanged(nameof(MyRating)); UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; } diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index db25a07e..554e877e 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -20,11 +20,11 @@ namespace LibationAvalonia.ViewModels public event EventHandler RemovableCountChanged; /// Backing list of all grid entries - private readonly List SOURCE = new(); + private readonly AvaloniaList SOURCE = new(); /// Grid entries included in the filter set. If null, all grid entries are shown private List FilteredInGridEntries; public string FilterString { get; private set; } - public DataGridCollectionView GridEntries { get; } + public DataGridCollectionView GridEntries { get; private set; } private bool _removeColumnVisivle; public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); } @@ -42,59 +42,60 @@ namespace LibationAvalonia.ViewModels public ProductsDisplayViewModel() { SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated; - GridEntries = new(SOURCE); - GridEntries.Filter = CollectionFilter; + VisibleCountChanged?.Invoke(this, 0); + } - GridEntries.CollectionChanged += (s, e) - => VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); + private static readonly System.Reflection.MethodInfo SetFlagsMethod; + + /// + /// Tells the whether it should process changes to the underlying collection + /// + /// DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged = 4 + 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 - /// - /// Call when there's been a change to the library - /// - public async Task DisplayBooksAsync(List dbBooks) + internal void BindToGrid(List dbBooks) { - try + GridEntries = new(SOURCE) { - var existingSeriesEntries = SOURCE.SeriesEntries().ToList(); + Filter = CollectionFilter + }; - FilteredInGridEntries?.Clear(); - SOURCE.Clear(); - SOURCE.AddRange(CreateGridEntries(dbBooks)); + GridEntries.CollectionChanged += (_, _) + => VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); - //If replacing the list, preserve user's existing collapse/expand - //state. When resetting a list, default state is cosed. - foreach (var series in existingSeriesEntries) - { - var sEntry = SOURCE.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId); - if (sEntry is SeriesEntry se) - se.Liberate.Expanded = series.Liberate.Expanded; - } - - //Run query on new list - FilteredInGridEntries = QueryResults(SOURCE, FilterString); - - await refreshGrid(); - - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel)); - } - } - - private async Task refreshGrid() - { - if (GridEntries.IsEditingItem) - await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit); - - await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh); - } - - private static List CreateGridEntries(IEnumerable dbBooks) - { var geList = dbBooks .Where(lb => lb.Book.IsProduct()) .Select(b => new LibraryBookEntry(b)) @@ -103,26 +104,159 @@ namespace LibationAvalonia.ViewModels var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()); - foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent())) + var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList(); + + foreach (var parent in seriesBooks) { var seriesEpisodes = episodes.FindChildren(parent); if (!seriesEpisodes.Any()) continue; var seriesEntry = new SeriesEntry(parent, seriesEpisodes); + seriesEntry.Liberate.Expanded = false; geList.Add(seriesEntry); geList.AddRange(seriesEntry.Children); } - var bookList = geList.OrderByDescending(e => e.DateAdded).ToList(); + //Create the filtered-in list before adding entries to avoid a refresh + FilteredInGridEntries = QueryResults(geList, FilterString); + SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded)); + } - //ListIndex is used by RowComparer to make column sort stable - int index = 0; - foreach (GridEntry di in bookList) - di.ListIndex = index++; + /// + /// Call when there's been a change to the library + /// + internal async Task UpdateGridAsync(List dbBooks) + { + #region Add new or update existing grid entries - return bookList; + //Add absent entries to grid, or update existing entry + var allEntries = SOURCE.BookEntries(); + var seriesEntries = SOURCE.SeriesEntries().ToList(); + var parentedEpisodes = dbBooks.ParentedEpisodes(); + + await Dispatcher.UIThread.InvokeAsync(() => + { + foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) + { + var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); + + if (libraryBook.Book.IsProduct()) + UpsertBook(libraryBook, existingEntry); + else if (parentedEpisodes.Any(lb => lb == 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.Parent is not null)) + 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); + } + + private void RemoveBooks(IEnumerable removedBooks, IEnumerable removedSeries) + { + foreach (var removed in removedBooks.Cast().Concat(removedSeries).Where(b => b is not null).ToList()) + { + if (GridEntries.PassesFilter(removed)) + 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 seriesEntries, IEnumerable 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, new[] { 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(episodeBook) { Parent = seriesEntry }; + seriesEntry.Children.Add(episodeEntry); + 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); + SOURCE.Insert(seriesIndex + 1, episodeEntry); + } + else + existingEpisodeEntry.UpdateLibraryBook(episodeBook); + } + + private async Task refreshGrid() + { + if (GridEntries.IsEditingItem) + await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit); + + await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh); } public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry) @@ -138,9 +272,6 @@ namespace LibationAvalonia.ViewModels public async Task Filter(string searchString) { - if (searchString == FilterString) - return; - FilterString = searchString; if (SOURCE.Count == 0) diff --git a/Source/LibationAvalonia/ViewModels/QueryExtensions.cs b/Source/LibationAvalonia/ViewModels/QueryExtensions.cs index e579c6a9..91357a45 100644 --- a/Source/LibationAvalonia/ViewModels/QueryExtensions.cs +++ b/Source/LibationAvalonia/ViewModels/QueryExtensions.cs @@ -14,6 +14,12 @@ namespace LibationAvalonia.ViewModels public static IEnumerable SeriesEntries(this IEnumerable gridEntries) => gridEntries.OfType(); + public static T? FindByAsin(this IEnumerable gridEntries, string audibleProductID) where T : GridEntry + => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); + + public static IEnumerable EmptySeries(this IEnumerable gridEntries) + => gridEntries.SeriesEntries().Where(i => i.Children.Count == 0); + public static SeriesEntry? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode) { if (seriesEpisode.Book.SeriesLink is null) return null; diff --git a/Source/LibationAvalonia/ViewModels/SeriesEntry.cs b/Source/LibationAvalonia/ViewModels/SeriesEntry.cs index 9f2d9749..6dbfcfb5 100644 --- a/Source/LibationAvalonia/ViewModels/SeriesEntry.cs +++ b/Source/LibationAvalonia/ViewModels/SeriesEntry.cs @@ -56,15 +56,36 @@ namespace LibationAvalonia.ViewModels { Liberate = new LiberateButtonStatus(IsSeries); SeriesIndex = -1; - LibraryBook = parent; - - LoadCover(); Children = children .Select(c => new LibraryBookEntry(c) { Parent = this }) .OrderBy(c => c.SeriesIndex) .ToList(); + setLibraryBook(parent); + LoadCover(); + } + + public void RemoveChild(LibraryBookEntry lbe) + { + Children.Remove(lbe); + PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); + int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); + Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; + } + + public void UpdateLibraryBook(LibraryBook libraryBook) + { + if (AudibleProductId != libraryBook.Book.AudibleProductId) + throw new Exception("Invalid grid entry update. IDs must match"); + + setLibraryBook(libraryBook); + } + + private void setLibraryBook(LibraryBook libraryBook) + { + LibraryBook = libraryBook; + Title = Book.Title; Series = Book.SeriesNames(); //Ratings are changed using Update(), which is a problem for Avalonia data bindings because @@ -81,6 +102,8 @@ namespace LibationAvalonia.ViewModels PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; + + this.RaisePropertyChanged(nameof(MyRating)); } diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 88f8a705..79fd7821 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -51,7 +51,7 @@ namespace LibationAvalonia.Views { this.LibraryLoaded += MainWindow_LibraryLoaded; - LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.DisplayBooksAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.UpdateGridAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); } Closing += MainWindow_Closing; @@ -67,7 +67,7 @@ namespace LibationAvalonia.Views if (QuickFilters.UseDefault) await performFilter(QuickFilters.Filters.FirstOrDefault()); - await _viewModel.ProductsDisplay.DisplayBooksAsync(dbBooks); + _viewModel.ProductsDisplay.BindToGrid(dbBooks); } private void InitializeComponent() diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index c7f2a29a..e168e6ef 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -44,7 +44,7 @@ namespace LibationAvalonia.Views }; var pdvm = new ProductsDisplayViewModel(); - _ = pdvm.DisplayBooksAsync(sampleEntries); + pdvm.BindToGrid(sampleEntries); DataContext = pdvm; return;