using ApplicationServices; using AudibleUtilities; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Threading; using DataLayer; using Dinah.Core.Collections.Generic; using LibationAvalonia.Dialogs.Login; using LibationFileManager; 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; namespace LibationAvalonia.ViewModels { public class ProductsDisplayViewModel : ViewModelBase { /// Number of visible rows has changed public event EventHandler VisibleCountChanged; public event EventHandler RemovableCountChanged; /// Backing list of all grid entries private readonly AvaloniaList SOURCE = new(); /// Grid entries included in the filter set. If null, all grid entries are shown private HashSet 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 GetVisibleBookEntries() => FilteredInGridEntries? .OfType() .Select(lbe => lbe.LibraryBook) .ToList() ?? SOURCE .OfType() .Select(lbe => lbe.LibraryBook) .ToList(); private IEnumerable GetAllBookEntries() => SOURCE .BookEntries(); public ProductsDisplayViewModel() { SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated; VisibleCountChanged?.Invoke(this, 0); } 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 internal async Task BindToGridAsync(List 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().Count() ?? SOURCE.OfType().Count(); VisibleCountChanged?.Invoke(this, count); } /// /// Call when there's been a change to the library /// internal async Task UpdateGridAsync(List dbBooks) { 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); GridEntries.CollectionChanged += GridEntries_CollectionChanged; GridEntries_CollectionChanged(); } 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, ILibraryBookEntry 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, ILibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) { if (existingEpisodeEntry is null) { ILibraryBookEntry 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.IsEditingItem) await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit); await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh); } public async Task ToggleSeriesExpanded(ISeriesEntry 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 ILibraryBookEntry 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; } 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(AvaloniaLoginChoiceEager.ApiExtendedFunc, 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(IGridEntry.Remove) && sender is ILibraryBookEntry) { 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 } }