474 lines
17 KiB
C#
474 lines
17 KiB
C#
using ApplicationServices;
|
|
using AudibleUtilities;
|
|
using Avalonia.Collections;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Threading;
|
|
using DataLayer;
|
|
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
|
|
{
|
|
/// <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<IGridEntry> SOURCE = new();
|
|
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
|
|
private HashSet<IGridEntry> 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<ILibraryBookEntry>()
|
|
.Select(lbe => lbe.LibraryBook)
|
|
.ToList()
|
|
?? SOURCE
|
|
.OfType<ILibraryBookEntry>()
|
|
.Select(lbe => lbe.LibraryBook)
|
|
.ToList();
|
|
|
|
private IEnumerable<ILibraryBookEntry> 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)
|
|
{
|
|
GridEntries = new(SOURCE) { Filter = CollectionFilter };
|
|
|
|
var geList = await LibraryBookEntry<AvaloniaEntryStatus>.GetAllProductsAsync(dbBooks);
|
|
|
|
var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks);
|
|
|
|
//Create the filtered-in list before adding entries to avoid a refresh
|
|
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString);
|
|
//Adding entries to the Source list will invoke CollectionFilter
|
|
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);
|
|
}
|
|
|
|
GridEntries.CollectionChanged += GridEntries_CollectionChanged;
|
|
GridEntries_CollectionChanged();
|
|
}
|
|
|
|
private void GridEntries_CollectionChanged(object sender = null, EventArgs e = null)
|
|
{
|
|
var count
|
|
= FilteredInGridEntries?.OfType<ILibraryBookEntry>().Count()
|
|
?? SOURCE.OfType<ILibraryBookEntry>().Count();
|
|
|
|
VisibleCountChanged?.Invoke(this, count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Call when there's been a change to the library
|
|
/// </summary>
|
|
internal async Task UpdateGridAsync(List<LibraryBook> 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().ToList();
|
|
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.FindByAsin(libraryBook.Book.AudibleProductId);
|
|
|
|
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<ILibraryBookEntry> removedBooks, IEnumerable<ISeriesEntry> removedSeries)
|
|
{
|
|
foreach (var removed in removedBooks.Cast<IGridEntry>().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<AvaloniaEntryStatus>(book));
|
|
else
|
|
// update existing
|
|
existingBookEntry.UpdateLibraryBook(book);
|
|
}
|
|
|
|
private void UpsertEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List<ISeriesEntry> seriesEntries, IEnumerable<LibraryBook> 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<AvaloniaEntryStatus>(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<AvaloniaEntryStatus>(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
|
|
}
|
|
}
|