From 3a61c3288127c51e471885b8fc0255ecd6f85099 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 13 Jul 2022 11:48:17 -0600 Subject: [PATCH] Fix sorting and refactor --- .../DataGridCheckBoxColumnExt.axaml.cs | 66 +-- .../AvaloniaUI/ViewModels/GridEntry2.cs | 40 +- .../ViewModels/GridEntryBindingList2.cs | 36 +- .../ViewModels/LiberateButtonStatus2.cs | 14 +- .../ViewModels/LibraryBookEntry2.cs | 25 +- .../ViewModels/ProductsDisplayViewModel.cs | 33 +- .../AvaloniaUI/ViewModels/RowComparer.cs | 95 +--- .../AvaloniaUI/ViewModels/SeriesEntrys2.cs | 34 +- .../MainWindow.VisibleBooks.axaml.cs | 12 +- .../Views/MainWindow/MainWindow.axaml | 3 +- .../Views/MainWindow/MainWindow.axaml.cs | 1 + .../Views/ProductsDisplay2.axaml.cs | 523 ------------------ .../ProductsDisplay2.Buttons.xaml.cs | 109 ++++ ...oductsDisplay2.ColumnCustomization.xaml.cs | 13 + .../ProductsDisplay2.Display.xaml.cs | 62 +++ .../ProductsDisplay2.Filtering.xaml.cs | 27 + .../ProductsDisplay2.ScanAndRemove.xaml.cs | 106 ++++ .../ProductsDisplay2.Sorting.xaml.cs | 65 +++ .../{ => ProductsGrid}/ProductsDisplay2.axaml | 12 +- .../ProductsGrid/ProductsDisplay2.axaml.cs | 121 ++++ 20 files changed, 649 insertions(+), 748 deletions(-) delete mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs rename Source/LibationWinForms/AvaloniaUI/Views/{ => ProductsGrid}/ProductsDisplay2.axaml (96%) create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml.cs diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs index b481dda4..d19b222d 100644 --- a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs @@ -1,79 +1,17 @@ using Avalonia.Controls; -using Avalonia.Interactivity; using LibationWinForms.AvaloniaUI.ViewModels; using System; -using System.Reflection; namespace LibationWinForms.AvaloniaUI.Controls { - /// The purpose of this extension it to immediately commit any check - /// state changes to the viewmodel. There must be a better way to do this, but - /// I sure as shit can't find it. public partial class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn { - Func _owningGrid_get; - Func _endCellEdit; - Func _waitForLostFocus; - public DataGrid OwningGrid - { - get - { - if (_owningGrid_get == null) - { - var pi = typeof(DataGridColumn).GetProperty(nameof(OwningGrid), BindingFlags.NonPublic | BindingFlags.Instance); - var mi = pi.GetGetMethod(true); - _owningGrid_get = mi.CreateDelegate>(this); - } - return _owningGrid_get(); - } - } - - public Func WaitForLostFocus - { - get - { - if (_endCellEdit == null) - { - var mi = typeof(DataGrid).GetMethod(nameof(WaitForLostFocus), BindingFlags.NonPublic | BindingFlags.Instance); - _waitForLostFocus = mi.CreateDelegate>(OwningGrid); - } - return _waitForLostFocus; - } - } - - public Func EndCellEdit - { - get - { - if (_endCellEdit == null) - { - var mi = typeof(DataGrid).GetMethod(nameof(EndCellEdit), BindingFlags.NonPublic | BindingFlags.Instance); - _endCellEdit = mi.CreateDelegate>(OwningGrid); - } - return _endCellEdit; - } - } - protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem) { + //Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary. var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox; - ele.Checked += EditingElement_Checked; - ele.Unchecked += EditingElement_Checked; - ele.Indeterminate += EditingElement_Checked; + ele.IsThreeState = dataItem is SeriesEntrys2; return ele; } - - private void EditingElement_Checked(object sender, RoutedEventArgs e) - { - if (sender is CheckBox cbox && cbox.DataContext is GridEntry2 gentry) - { - var check = cbox.IsChecked; - WaitForLostFocus(() => - { - EndCellEdit(DataGridEditAction.Cancel, true, true, false); - gentry.Remove = check; - }); - } - } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs index 3f866bf3..2b85abd6 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs @@ -1,4 +1,5 @@ -using DataLayer; +using Avalonia.Media; +using DataLayer; using Dinah.Core; using Dinah.Core.DataBinding; using Dinah.Core.Drawing; @@ -28,39 +29,32 @@ namespace LibationWinForms.AvaloniaUI.ViewModels [Browsable(false)] public float SeriesIndex { get; protected set; } [Browsable(false)] public string LongDescription { get; protected set; } [Browsable(false)] public abstract DateTime DateAdded { get; } + [Browsable(false)] public int ListIndex { get; set; } [Browsable(false)] protected Book Book => LibraryBook.Book; #region Model properties exposed to the view private Avalonia.Media.Imaging.Bitmap _cover; - 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 string _productRating; - private string _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 string ProductRating { get => _productRating; protected set { this.RaiseAndSetIfChanged(ref _productRating, value); } } - public string MyRating { get => _myRating; protected set { this.RaiseAndSetIfChanged(ref _myRating, 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 string ProductRating { get; protected set; } + public string MyRating { get; protected set; } protected bool? _remove = false; public abstract bool? Remove { get; set; } public abstract LiberateButtonStatus2 Liberate { get; } public abstract BookTags BookTags { get; } + public abstract bool IsSeries { get; } + public abstract bool IsEpisode { get; } + public abstract bool IsBook { get; } #endregion diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs index 48b3f90d..3448e88b 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs @@ -48,6 +48,13 @@ namespace LibationWinForms.AvaloniaUI.ViewModels base.Remove(entry); } + public void ReplaceList(IEnumerable newItems) + { + Items.Clear(); + ((List)Items).AddRange(newItems); + ResetCollection(); + } + protected override void InsertItem(int index, GridEntry2 item) { FilterRemoved.Remove(item); @@ -120,10 +127,22 @@ namespace LibationWinForms.AvaloniaUI.ViewModels { foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList()) { - Remove(episode); + /* + * Bypass ObservationCollection's InsertItem methos so that CollectionChanged isn't + * fired. When adding many items at once, Avalonia's CollectionChanged event handler + * causes serious performance problems. And unfotrunately, Avalonia doesn't respect + * the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) + * overload that would fire only once for all changed items. + * + * Doing this requires resetting the list so the view knows it needs to rebuild its display. + */ + + FilterRemoved.Add(episode); + Items.Remove(episode); } sEntry.Liberate.Expanded = false; + ResetCollection(); } public void ExpandItem(SeriesEntrys2 sEntry) @@ -134,10 +153,23 @@ namespace LibationWinForms.AvaloniaUI.ViewModels { if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) { - InsertItem(++sindex, episode); + /* + * Bypass ObservationCollection's InsertItem methos so that CollectionChanged isn't + * fired. When adding many items at once, Avalonia's CollectionChanged event handler + * causes serious performance problems. And unfotrunately, Avalonia doesn't respect + * the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) + * overload that would fire only once for all changed items. + * + * Doing this requires resetting the list so the view knows it needs to rebuild its display. + */ + + FilterRemoved.Remove(episode); + Items.Insert(++sindex, episode); } } + sEntry.Liberate.Expanded = true; + ResetCollection(); } #endregion diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs index f17c519c..2f506133 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs @@ -8,6 +8,10 @@ namespace LibationWinForms.AvaloniaUI.ViewModels { public class LiberateButtonStatus2 : ViewModelBase, IComparable { + public LiberateButtonStatus2(bool isSeries) + { + IsSeries = isSeries; + } public LiberatedStatus BookStatus { get; set; } public LiberatedStatus? PdfStatus { get; set; } @@ -22,11 +26,11 @@ namespace LibationWinForms.AvaloniaUI.ViewModels this.RaisePropertyChanged(nameof(ToolTip)); } } - public bool IsSeries { get; init; } + private bool IsSeries { get; } public Bitmap Image => GetLiberateIcon(); public string ToolTip => GetTooltip(); - static Dictionary images = new(); + static Dictionary iconCache = new(); /// Defines the Liberate column's sorting behavior public int CompareTo(object obj) @@ -106,14 +110,14 @@ namespace LibationWinForms.AvaloniaUI.ViewModels private static Bitmap GetFromResources(string rescName) { - if (images.ContainsKey(rescName)) return images[rescName]; + if (iconCache.ContainsKey(rescName)) return iconCache[rescName]; var memoryStream = new System.IO.MemoryStream(); ((System.Drawing.Bitmap)Properties.Resources.ResourceManager.GetObject(rescName)).Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); memoryStream.Position = 0; - images[rescName] = new Bitmap(memoryStream); - return images[rescName]; + iconCache[rescName] = new Bitmap(memoryStream); + return iconCache[rescName]; } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs index 7e46c741..0b5c2f61 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs @@ -1,7 +1,6 @@ using ApplicationServices; using DataLayer; using Dinah.Core; -using LibationWinForms.GridView; using ReactiveUI; using System; using System.Collections.Generic; @@ -27,7 +26,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels get => _remove; set { - _remove = value.HasValue ? value.Value : false; + _remove = value ?? false; Parent?.ChildRemoveUpdate(); this.RaisePropertyChanged(nameof(Remove)); @@ -45,32 +44,22 @@ namespace LibationWinForms.AvaloniaUI.ViewModels _pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book); lastStatusUpdate = DateTime.Now; } - return new LiberateButtonStatus2 { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false }; + return new LiberateButtonStatus2(IsSeries) { BookStatus = _bookStatus, PdfStatus = _pdfStatus }; } } public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) }; + public override bool IsSeries => false; + public override bool IsEpisode => Parent is not null; + public override bool IsBook => Parent is null; + #endregion public LibraryBookEntry2(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; + LoadCover(); Title = Book.Title; Series = Book.SeriesNames(); diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs index d0cd021f..de7dfb21 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs @@ -13,7 +13,21 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public class ProductsDisplayViewModel : ViewModelBase { public GridEntryBindingList2 GridEntries { get; set; } + public DataGridCollectionView GridCollectionView { get; set; } public ProductsDisplayViewModel(IEnumerable dbBooks) + { + GridEntries = new GridEntryBindingList2(CreateGridEntries(dbBooks)); + GridEntries.CollapseAll(); + + /* + * Would be nice to use built-in groups, but Avalonia doesn't yet let you customize the row group header. + * + GridCollectionView = new DataGridCollectionView(GridEntries); + GridCollectionView.GroupDescriptions.Add(new CustonGroupDescription()); + */ + } + + public static IEnumerable CreateGridEntries(IEnumerable dbBooks) { var geList = dbBooks .Where(lb => lb.Book.IsProduct()) @@ -36,9 +50,22 @@ namespace LibationWinForms.AvaloniaUI.ViewModels geList.Add(seriesEntry); geList.AddRange(seriesEntry.Children); } - - GridEntries = new GridEntryBindingList2(geList.OrderByDescending(e => e.DateAdded)); - GridEntries.CollapseAll(); + return geList.OrderByDescending(e => e.DateAdded); + } + } + class CustonGroupDescription : DataGridGroupDescription + { + public override object GroupKeyFromItem(object item, int level, CultureInfo culture) + { + if (item is SeriesEntrys2 sEntry) + return sEntry; + else if (item is LibraryBookEntry2 lbEntry && lbEntry.Parent is SeriesEntrys2 sEntry2) + return sEntry2; + else return null; + } + public override bool KeysMatch(object groupKey, object itemKey) + { + return base.KeysMatch(groupKey, itemKey); } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs index cdc6c274..9738a18c 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs @@ -3,21 +3,20 @@ using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Reflection; namespace LibationWinForms.AvaloniaUI.ViewModels { /// /// This compare class ensures that all top-level grid entries (standalone books or series parents) /// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain - /// sorted by series index, ascending. + /// sorted by series index, ascending. Stable sorting is achieved by comparing the GridEntry.ListIndex + /// properties when 2 items compare equal. /// - internal class RowComparer : IComparer, IComparer + internal class RowComparer : IComparer { - private static readonly System.Reflection.PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - private static readonly System.Reflection.PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + private static readonly PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance); + private static readonly PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance); public DataGridColumn Column { get; init; } public string PropertyName { get; private set; } @@ -34,7 +33,6 @@ namespace LibationWinForms.AvaloniaUI.ViewModels PropertyName = propertyName; } - public int Compare(object x, object y) { if (x is null && y is not null) return -1; @@ -54,71 +52,26 @@ namespace LibationWinForms.AvaloniaUI.ViewModels if (geB is LibraryBookEntry2 lbB && lbB.Parent is SeriesEntrys2 seB) parentB = seB; - if (geA is SeriesEntrys2 && geB is SeriesEntrys2) - { - //Both are parents. Make sure they never compare equal. - var comparison = InternalCompare(geA, geB); - if (comparison == 0) - { - var propBackup = PropertyName; - PropertyName = nameof(GridEntry2.Series); - comparison = InternalCompare(geA, geB); - PropertyName = propBackup; - return comparison; - } - return comparison; - } - - - - //both a and b are standalone + //both a and b are top-level grid entries if (parentA is null && parentB is null) return InternalCompare(geA, geB); - //a is a standalone, b is a child + //a is top-level, b is a child if (parentA is null && parentB is not null) { // b is a child of a, parent is always first if (parentB == geA) return SortDirection is ListSortDirection.Ascending ? -1 : 1; - else if (geA is SeriesEntrys2) - { - //Both are parents. Make sure they never compare equal. - var comparison = InternalCompare(geA, parentB); - if (comparison == 0) - { - var propBackup = PropertyName; - PropertyName = nameof(GridEntry2.Series); - comparison = InternalCompare(geA, parentB); - PropertyName = propBackup; - return comparison; - } - return comparison; - } else return InternalCompare(geA, parentB); } - //a is a child, b is a standalone + //a is a child, b is a top-level if (parentA is not null && parentB is null) { // a is a child of b, parent is always first if (parentA == geB) return SortDirection is ListSortDirection.Ascending ? 1 : -1; - else if (geB is SeriesEntrys2) - { - //Both are parents. Make sure they never compare equal. - var comparison = InternalCompare(parentA, geB); - if (comparison == 0) - { - var propBackup = PropertyName; - PropertyName = nameof(GridEntry2.Series); - comparison = InternalCompare(parentA, geB); - PropertyName = propBackup; - return comparison; - } - return comparison; - } else return InternalCompare(parentA, geB); } @@ -127,17 +80,8 @@ namespace LibationWinForms.AvaloniaUI.ViewModels if (parentA == parentB) return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); - //a and b are children of different series. Make sure their parents never compare equal. - var comparison2 = InternalCompare(parentA, parentB); - if (comparison2 == 0) - { - var propBackup = PropertyName; - PropertyName = nameof(GridEntry2.Series); - comparison2 = InternalCompare(parentA, parentB); - PropertyName = propBackup; - return comparison2; - } - return comparison2; + //a and b are children of different series. + return Compare(parentA, parentB); } //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection @@ -149,17 +93,14 @@ namespace LibationWinForms.AvaloniaUI.ViewModels var val1 = x.GetMemberValue(PropertyName); var val2 = y.GetMemberValue(PropertyName); - return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); - } + var compareResult = x.GetMemberComparer(val1.GetType()).Compare(val1, val2); - public int CompareTo(GridEntry2 other) - { - return Compare(this, other); - } - - public int Compare(GridEntry2 x, GridEntry2 y) - { - return Compare((object)x, (object)y) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); + //If items compare equal, compare them by their positions in the the list. + //This is how you achieve a stable sort. + if (compareResult == 0) + return x.ListIndex.CompareTo(y.ListIndex); + else + return compareResult; } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs index 21c41bcb..4cec5386 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs @@ -1,4 +1,5 @@ -using DataLayer; +using Avalonia.Media; +using DataLayer; using Dinah.Core; using ReactiveUI; using System; @@ -31,7 +32,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels get => _remove; set { - _remove = value.HasValue ? value.Value : false; + _remove = value ?? false; suspendCounting = true; @@ -46,39 +47,28 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public override LiberateButtonStatus2 Liberate { get; } public override BookTags BookTags { get; } = new(); + public override bool IsSeries => true; + public override bool IsEpisode => false; + public override bool IsBook => false; + #endregion - private SeriesEntrys2(LibraryBook parent) + public SeriesEntrys2(LibraryBook parent, IEnumerable children) { - Liberate = new LiberateButtonStatus2 { IsSeries = true }; + Liberate = new LiberateButtonStatus2(IsSeries); SeriesIndex = -1; LibraryBook = parent; - LoadCover(); - } - public SeriesEntrys2(LibraryBook parent, IEnumerable children) : this(parent) - { + LoadCover(); + Children = children .Select(c => new LibraryBookEntry2(c) { Parent = this }) .OrderBy(c => c.SeriesIndex) .ToList(); - UpdateSeries(parent); - } - - public SeriesEntrys2(LibraryBook parent, LibraryBook child) : this(parent) - { - Children = new() { new LibraryBookEntry2(child) { Parent = this } }; - UpdateSeries(parent); - } - - public void UpdateSeries(LibraryBook parent) - { - LibraryBook = parent; Title = Book.Title; Series = Book.SeriesNames(); MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); Authors = Book.AuthorNames(); Narrators = Book.NarratorNames(); @@ -87,10 +77,12 @@ namespace LibationWinForms.AvaloniaUI.ViewModels LongDescription = GetDescriptionDisplay(Book); Description = TrimTextToWord(LongDescription, 62); + 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"; } + #region Data Sorting /// Create getters for all member object values by name diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs index 4523ce71..8b45239b 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs @@ -40,7 +40,7 @@ namespace LibationWinForms.AvaloniaUI.Views processBookQueue1.AddDownloadDecrypt( productsDisplay - .GetVisible() + .GetVisibleBookEntries() .UnLiberated() ); } @@ -56,7 +56,7 @@ namespace LibationWinForms.AvaloniaUI.Views if (result != System.Windows.Forms.DialogResult.OK) return; - var visibleLibraryBooks = productsDisplay.GetVisible(); + var visibleLibraryBooks = productsDisplay.GetVisibleBookEntries(); var confirmationResult = MessageBoxLib.ShowConfirmationDialog( visibleLibraryBooks, @@ -78,7 +78,7 @@ namespace LibationWinForms.AvaloniaUI.Views if (result != System.Windows.Forms.DialogResult.OK) return; - var visibleLibraryBooks = productsDisplay.GetVisible(); + var visibleLibraryBooks = productsDisplay.GetVisibleBookEntries(); var confirmationResult = MessageBoxLib.ShowConfirmationDialog( visibleLibraryBooks, @@ -95,7 +95,7 @@ namespace LibationWinForms.AvaloniaUI.Views public async void removeToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) { - var visibleLibraryBooks = productsDisplay.GetVisible(); + var visibleLibraryBooks = productsDisplay.GetVisibleBookEntries(); var confirmationResult = MessageBoxLib.ShowConfirmationDialog( visibleLibraryBooks, @@ -121,13 +121,13 @@ namespace LibationWinForms.AvaloniaUI.Views }); //Not used for anything? - var notLiberatedCount = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); + var notLiberatedCount = productsDisplay.GetVisibleBookEntries().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); await Task.Run(setLiberatedVisibleMenuItem); } void setLiberatedVisibleMenuItem() { - var notLiberated = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); + var notLiberated = productsDisplay.GetVisibleBookEntries().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); Dispatcher.UIThread.Post(() => { diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml index 47c66173..fcdb7ff1 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml @@ -5,6 +5,7 @@ xmlns:vm="clr-namespace:LibationWinForms.AvaloniaUI.ViewModels" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:views="clr-namespace:LibationWinForms.AvaloniaUI.Views" + xmlns:prgid="clr-namespace:LibationWinForms.AvaloniaUI.Views.ProductsGrid" xmlns:controls="clr-namespace:LibationWinForms.AvaloniaUI.Controls" mc:Ignorable="d" d:DesignWidth="2000" d:DesignHeight="700" x:Class="LibationWinForms.AvaloniaUI.Views.MainWindow" Title="MainWindow"> @@ -145,7 +146,7 @@ - Number of visible rows has changed - public event EventHandler VisibleCountChanged; - public event EventHandler RemovableCountChanged; - public event EventHandler LiberateClicked; - public event EventHandler InitialLoaded; - - private ProductsDisplayViewModel _viewModel; - private GridEntryBindingList2 bindingList => _viewModel.GridEntries; - private IEnumerable GetAllBookEntries() - => bindingList.AllItems().BookEntries(); - - internal List GetVisible() - => bindingList - .BookEntries() - .Select(lbe => lbe.LibraryBook) - .ToList(); - - - DataGridColumn removeGVColumn; - DataGridColumn liberateGVColumn; - DataGridColumn coverGVColumn; - DataGridColumn titleGVColumn; - DataGridColumn authorsGVColumn; - DataGridColumn narratorsGVColumn; - DataGridColumn lengthGVColumn; - DataGridColumn seriesGVColumn; - DataGridColumn descriptionGVColumn; - DataGridColumn categoryGVColumn; - DataGridColumn productRatingGVColumn; - DataGridColumn purchaseDateGVColumn; - DataGridColumn myRatingGVColumn; - DataGridColumn miscGVColumn; - DataGridColumn tagAndDetailsGVColumn; - - #region Init - - public ProductsDisplay2() - { - InitializeComponent(); - - - if (Design.IsDesignMode) - { - using var context = DbContexts.GetContext(); - var book = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"); - productsGrid.DataContext = _viewModel = new ProductsDisplayViewModel(new List { book }); - return; - } - } - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - - productsGrid = this.FindControl(nameof(productsGrid)); - productsGrid.Sorting += ProductsGrid_Sorting; - productsGrid.CanUserSortColumns = true; - productsGrid.LoadingRow += ProductsGrid_LoadingRow; - - removeGVColumn = productsGrid.Columns[0]; - liberateGVColumn = productsGrid.Columns[1]; - coverGVColumn = productsGrid.Columns[2]; - titleGVColumn = productsGrid.Columns[3]; - authorsGVColumn = productsGrid.Columns[4]; - narratorsGVColumn = productsGrid.Columns[5]; - lengthGVColumn = productsGrid.Columns[6]; - seriesGVColumn = productsGrid.Columns[7]; - descriptionGVColumn = productsGrid.Columns[8]; - categoryGVColumn = productsGrid.Columns[9]; - productRatingGVColumn = productsGrid.Columns[10]; - purchaseDateGVColumn = productsGrid.Columns[11]; - myRatingGVColumn = productsGrid.Columns[12]; - miscGVColumn = productsGrid.Columns[13]; - tagAndDetailsGVColumn = productsGrid.Columns[14]; - - RegisterCustomColumnComparers(); - } - - #endregion - - #region Apply Background Brush Style to Series Books Rows - - private static object tagObj = new(); - private void ProductsGrid_LoadingRow(object sender, DataGridRowEventArgs e) - { - if (e.Row.Tag == tagObj) - return; - e.Row.Tag = tagObj; - - static IBrush GetRowColor(DataGridRow row) - => row.DataContext is GridEntry2 gEntry - && gEntry is LibraryBookEntry2 lbEntry - && lbEntry.Parent is not null - ? App.SeriesEntryGridBackgroundBrush - : null; - - e.Row.Background = GetRowColor(e.Row); - e.Row.DataContextChanged += (sender, e) => - { - var row = sender as DataGridRow; - row.Background = GetRowColor(row); - }; - } - - #endregion - - #region Filter - - public void Filter(string searchString) - { - int visibleCount = bindingList.Count; - - if (string.IsNullOrEmpty(searchString)) - bindingList.RemoveFilter(); - else - bindingList.Filter = searchString; - - if (visibleCount != bindingList.Count) - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - - //Re-sort after filtering - ReSort(); - } - - #endregion - - #region Sorting - - private void RegisterCustomColumnComparers() - { - - removeGVColumn.CustomSortComparer = new RowComparer(removeGVColumn); - liberateGVColumn.CustomSortComparer = new RowComparer(liberateGVColumn); - titleGVColumn.CustomSortComparer = new RowComparer(titleGVColumn); - authorsGVColumn.CustomSortComparer = new RowComparer(authorsGVColumn); - narratorsGVColumn.CustomSortComparer = new RowComparer(narratorsGVColumn); - lengthGVColumn.CustomSortComparer = new RowComparer(lengthGVColumn); - seriesGVColumn.CustomSortComparer = new RowComparer(seriesGVColumn); - descriptionGVColumn.CustomSortComparer = new RowComparer(descriptionGVColumn); - categoryGVColumn.CustomSortComparer = new RowComparer(categoryGVColumn); - productRatingGVColumn.CustomSortComparer = new RowComparer(productRatingGVColumn); - purchaseDateGVColumn.CustomSortComparer = new RowComparer(purchaseDateGVColumn); - myRatingGVColumn.CustomSortComparer = new RowComparer(myRatingGVColumn); - miscGVColumn.CustomSortComparer = new RowComparer(miscGVColumn); - tagAndDetailsGVColumn.CustomSortComparer = new RowComparer(tagAndDetailsGVColumn); - } - - private void ReSort() - { - if (CurrentSortColumn is null) - { - bindingList.InternalList.Sort(new RowComparer(ListSortDirection.Descending, nameof(GridEntry2.DateAdded))); - bindingList.ResetCollection(); - } - else - CurrentSortColumn.Sort(((RowComparer)CurrentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); - } - - - private DataGridColumn CurrentSortColumn; - - private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e) - { - var comparer = e.Column.CustomSortComparer as RowComparer; - //Force the comparer to get the current sort order. We can't - //retrieve it from inside this event handler because Avalonia - //doesn't set the property until after this event. - comparer.SortDirection = null; - CurrentSortColumn = e.Column; - } - - - #endregion - - #region Button controls - - public void LiberateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) - { - var button = args.Source as Button; - - if (button.DataContext is SeriesEntrys2 sEntry) - { - if (sEntry.Liberate.Expanded) - bindingList.CollapseItem(sEntry); - else - { - bindingList.ExpandItem(sEntry); - ReSort(); - } - - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - } - else if (button.DataContext is LibraryBookEntry2 lbEntry) - { - LiberateClicked?.Invoke(this, lbEntry.LibraryBook); - } - } - - private GridView.ImageDisplay imageDisplay; - public async void Cover_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) - { - if (sender is not Image tblock || tblock.DataContext is not GridEntry2 gEntry) - return; - - var picDefinition = new PictureDefinition(gEntry.LibraryBook.Book.PictureLarge ?? gEntry.LibraryBook.Book.PictureId, PictureSize.Native); - var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); - - (_, byte[] initialImageBts) = PictureStorage.GetPicture(new PictureDefinition(gEntry.LibraryBook.Book.PictureId, PictureSize._80x80)); - var windowTitle = $"{gEntry.Title} - Cover"; - - if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) - { - imageDisplay = new GridView.ImageDisplay(); - imageDisplay.RestoreSizeAndLocation(Configuration.Instance); - imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); - imageDisplay.Show(null); - } - - imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(gEntry.LibraryBook); - imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(gEntry.LibraryBook, ".jpg")); - imageDisplay.Text = windowTitle; - imageDisplay.CoverPicture = initialImageBts; - imageDisplay.CoverPicture = await picDlTask; - } - - public void Description_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) - { - if (sender is TextBlock tblock && tblock.DataContext is GridEntry2 gEntry) - { - var pt = tblock.Parent.PointToScreen(tblock.Parent.Bounds.TopRight); - var displayWindow = new GridView.DescriptionDisplay - { - SpawnLocation = new System.Drawing.Point(pt.X, pt.Y), - DescriptionText = gEntry.LongDescription, - BorderThickness = 2, - }; - - void CloseWindow(object o, DataGridRowEventArgs e) - { - displayWindow.Close(); - } - productsGrid.LoadingRow += CloseWindow; - displayWindow.FormClosed += (_, _) => - { - productsGrid.LoadingRow -= CloseWindow; - }; - - displayWindow.Show(); - } - } - - public void OnTagsButtonClick(object sender, Avalonia.Interactivity.RoutedEventArgs args) - { - var button = args.Source as Button; - - if (button.DataContext is LibraryBookEntry2 lbEntry) - { - var bookDetailsForm = new Dialogs.BookDetailsDialog(lbEntry.LibraryBook); - if (bookDetailsForm.ShowDialog() == System.Windows.Forms.DialogResult.OK) - lbEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); - } - } - - #endregion - - #region Scan and Remove Books - - public void CloseRemoveBooksColumn() - => removeGVColumn.IsVisible = false; - - public async Task RemoveCheckedBooksAsync() - { - var selectedBooks = GetAllBookEntries().Where(lbe => lbe.Remove == true).ToList(); - - if (selectedBooks.Count == 0) - return; - - var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); - var result = MessageBoxLib.ShowConfirmationDialog( - libraryBooks, - $"Are you sure you want to remove {selectedBooks.Count} books from Libation's library?", - "Remove books from Libation?"); - - if (result != System.Windows.Forms.DialogResult.Yes) - return; - - RemoveBooks(selectedBooks); - var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); - var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); - - RemovableCountChanged?.Invoke(this, GetAllBookEntries().Count(lbe => lbe.Remove is true)); - } - public async Task ScanAndRemoveBooksAsync(params Account[] accounts) - { - RemovableCountChanged?.Invoke(this, 0); - removeGVColumn.IsVisible = true; - - try - { - if (accounts is null || accounts.Length == 0) - return; - - var allBooks = GetAllBookEntries(); - - foreach (var b in allBooks) - b.Remove = false; - - var lib = allBooks - .Select(lbe => lbe.LibraryBook) - .Where(lb => !lb.Book.HasLiberated()); - - var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.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; - - RemovableCountChanged?.Invoke(this, GetAllBookEntries().Count(lbe => lbe.Remove is true)); - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert( - null, - "Error scanning library. You may still manually select books to remove from Libation's library.", - "Error scanning library", - ex); - } - } - - - #endregion - - #region UI display functions - - public void Display() - { - try - { - // don't return early if lib size == 0. this will not update correctly if all books are removed - var dbBooks = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true); - if (productsGrid.DataContext is null) - { - productsGrid.DataContext = _viewModel = new ProductsDisplayViewModel(dbBooks); - InitialLoaded?.Invoke(this, EventArgs.Empty); - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - } - else - UpdateGrid(dbBooks); - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay2)); - } - } - - private void UpdateGrid(List dbBooks) - { - #region Add new or update existing grid entries - - //Remove filter prior to adding/updating boooks - string existingFilter = bindingList.Filter; - Filter(null); - - bindingList.SuspendFilteringOnUpdate = true; - - //Add absent entries to grid, or update existing entry - - var allEntries = bindingList.AllItems().BookEntries(); - var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); - var parentedEpisodes = dbBooks.ParentedEpisodes(); - - foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) - { - var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); - - if (libraryBook.Book.IsProduct()) - AddOrUpdateBook(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 - AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); - } - - bindingList.SuspendFilteringOnUpdate = false; - - //Re-apply filter after adding new/updating existing books to capture any changes - Filter(existingFilter); - - #endregion - - // 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 = - bindingList - .AllItems() - .BookEntries() - .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); - - RemoveBooks(removedBooks); - } - - private void RemoveBooks(IEnumerable removedBooks) - { - //Remove books in series from their parents' Children list - foreach (var removed in removedBooks.Where(b => b.Parent is not null)) - { - removed.Parent.Children.Remove(removed); - - //In Avalonia, if you fire PropertyChanged with an empty or invalid property name, nothing is updated. - //So we must notify for specific properties that we believed changed. - removed.Parent.RaisePropertyChanged(nameof(SeriesEntrys2.Length)); - removed.Parent.RaisePropertyChanged(nameof(SeriesEntrys2.PurchaseDate)); - } - - //Remove series that have no children - var removedSeries = - bindingList - .AllItems() - .EmptySeries(); - - foreach (var removed in removedBooks.Cast().Concat(removedSeries)) - //no need to re-filter for removed books - bindingList.Remove(removed); - - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - } - - private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry2 existingBookEntry) - { - if (existingBookEntry is null) - // Add the new product to top - bindingList.Insert(0, new LibraryBookEntry2(book)); - else - // update existing - existingBookEntry.UpdateLibraryBook(book); - } - - private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry2 existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) - { - if (existingEpisodeEntry is null) - { - LibraryBookEntry2 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 SeriesEntrys2(seriesBook, episodeBook); - seriesEntries.Add(seriesEntry); - - episodeEntry = seriesEntry.Children[0]; - seriesEntry.Liberate.Expanded = true; - bindingList.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.UpdateSeries(seriesBook); - } - - //Add episode to the grid beneath the parent - int seriesIndex = bindingList.IndexOf(seriesEntry); - bindingList.Insert(seriesIndex + 1, episodeEntry); - - if (seriesEntry.Liberate.Expanded) - bindingList.ExpandItem(seriesEntry); - else - bindingList.CollapseItem(seriesEntry); - - seriesEntry.RaisePropertyChanged(nameof(SeriesEntrys2.Length)); - seriesEntry.RaisePropertyChanged(nameof(SeriesEntrys2.PurchaseDate)); - } - else - existingEpisodeEntry.UpdateLibraryBook(episodeBook); - } - - #endregion - - #region Column Customizations - - - - #endregion - } -} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs new file mode 100644 index 00000000..bdfd6148 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs @@ -0,0 +1,109 @@ +using Avalonia; +using Avalonia.Controls; +using FileLiberator; +using LibationFileManager; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + + private GridView.ImageDisplay imageDisplay; + private void Configure_Buttons() { } + + public void LiberateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var button = args.Source as Button; + + if (button.DataContext is SeriesEntrys2 sEntry) + { + if (sEntry.Liberate.Expanded) + { + bindingList.CollapseItem(sEntry); + } + else + { + bindingList.ExpandItem(sEntry); + } + + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + + //Expanding and collapsing reset the list, which will cause focus to shift + //to the topright cell. Reset focus onto the clicked button's cell. + ((sender as Control).Parent.Parent as DataGridCell)?.Focus(); + } + else if (button.DataContext is LibraryBookEntry2 lbEntry) + { + LiberateClicked?.Invoke(this, lbEntry.LibraryBook); + } + } + + public async void Cover_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + if (sender is not Image tblock || tblock.DataContext is not GridEntry2 gEntry) + return; + + var picDefinition = new PictureDefinition(gEntry.LibraryBook.Book.PictureLarge ?? gEntry.LibraryBook.Book.PictureId, PictureSize.Native); + var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); + + (_, byte[] initialImageBts) = PictureStorage.GetPicture(new PictureDefinition(gEntry.LibraryBook.Book.PictureId, PictureSize._80x80)); + var windowTitle = $"{gEntry.Title} - Cover"; + + if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) + { + imageDisplay = new GridView.ImageDisplay(); + imageDisplay.RestoreSizeAndLocation(Configuration.Instance); + imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); + imageDisplay.Show(null); + } + + imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(gEntry.LibraryBook); + imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(gEntry.LibraryBook, ".jpg")); + imageDisplay.Text = windowTitle; + imageDisplay.CoverPicture = initialImageBts; + imageDisplay.CoverPicture = await picDlTask; + } + + public void Description_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + if (sender is TextBlock tblock && tblock.DataContext is GridEntry2 gEntry) + { + var pt = tblock.Parent.PointToScreen(tblock.Parent.Bounds.TopRight); + var displayWindow = new GridView.DescriptionDisplay + { + SpawnLocation = new System.Drawing.Point(pt.X, pt.Y), + DescriptionText = gEntry.LongDescription, + BorderThickness = 2, + }; + + void CloseWindow(object o, DataGridRowEventArgs e) + { + displayWindow.Close(); + } + productsGrid.LoadingRow += CloseWindow; + displayWindow.FormClosed += (_, _) => + { + productsGrid.LoadingRow -= CloseWindow; + }; + + displayWindow.Show(); + } + } + + public void OnTagsButtonClick(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var button = args.Source as Button; + + if (button.DataContext is LibraryBookEntry2 lbEntry) + { + var bookDetailsForm = new Dialogs.BookDetailsDialog(lbEntry.LibraryBook); + if (bookDetailsForm.ShowDialog() == System.Windows.Forms.DialogResult.OK) + lbEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs new file mode 100644 index 00000000..50fe68d2 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_ColumnCustomization() { } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs new file mode 100644 index 00000000..5c934399 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs @@ -0,0 +1,62 @@ +using ApplicationServices; +using Avalonia.Controls; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Collections; +using System.Linq; +using System.Reflection; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_Display() { } + + public void Display() + { + try + { + var dbBooks = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true); + + if (_viewModel is null) + { + _viewModel = new ProductsDisplayViewModel(dbBooks); + InitialLoaded?.Invoke(this, EventArgs.Empty); + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + + //Avalonia displays items in the DataConncetion from an internal copy of + //the bound list, not the actual bound list. so we need to reflect to get + //the current display order and set the GridEntry.ListIndex correctly. + var DataConnection_PI = typeof(DataGrid).GetProperty("DataConnection", BindingFlags.NonPublic | BindingFlags.Instance); + var DataSource_PI = DataConnection_PI.PropertyType.GetProperty("DataSource", BindingFlags.Public | BindingFlags.Instance); + + bindingList.CollectionChanged += (s, e) => + { + var displayListGE = ((IEnumerable)DataSource_PI.GetValue(DataConnection_PI.GetValue(productsGrid))).Cast(); + int index = 0; + foreach (var di in displayListGE) + { + di.ListIndex = index++; + } + }; + + //Assign the viewmodel after we subscribe to CollectionChanged + //to ensure that out handler executes first. + productsGrid.DataContext = _viewModel; + } + else + { + string existingFilter = _viewModel?.GridEntries?.Filter; + bindingList.ReplaceList(ProductsDisplayViewModel.CreateGridEntries(dbBooks)); + bindingList.Filter = existingFilter; + ReSort(); + } + + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay2)); + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs new file mode 100644 index 00000000..17388697 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs @@ -0,0 +1,27 @@ +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_Filtering() { } + + public void Filter(string searchString) + { + int visibleCount = bindingList.Count; + + if (string.IsNullOrEmpty(searchString)) + bindingList.RemoveFilter(); + else + bindingList.Filter = searchString; + + if (visibleCount != bindingList.Count) + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + + //Re-sort after filtering + ReSort(); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs new file mode 100644 index 00000000..025ed866 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs @@ -0,0 +1,106 @@ +using ApplicationServices; +using AudibleUtilities; +using DataLayer; +using LibationWinForms.AvaloniaUI.ViewModels; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_ScanAndRemove() { } + + public void CloseRemoveBooksColumn() + => removeGVColumn.IsVisible = false; + + public async Task RemoveCheckedBooksAsync() + { + var selectedBooks = GetAllBookEntries().Where(lbe => lbe.Remove == true).ToList(); + + if (selectedBooks.Count == 0) + return; + + var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); + var result = MessageBoxLib.ShowConfirmationDialog( + libraryBooks, + $"Are you sure you want to remove {selectedBooks.Count} books from Libation's library?", + "Remove books from Libation?"); + + if (result != System.Windows.Forms.DialogResult.Yes) + return; + + RemoveBooks(selectedBooks); + var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); + var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); + + RemovableCountChanged?.Invoke(this, GetAllBookEntries().Count(lbe => lbe.Remove is true)); + } + public async Task ScanAndRemoveBooksAsync(params Account[] accounts) + { + RemovableCountChanged?.Invoke(this, 0); + removeGVColumn.IsVisible = true; + + try + { + if (accounts is null || accounts.Length == 0) + return; + + var allBooks = GetAllBookEntries(); + + foreach (var b in allBooks) + b.Remove = false; + + var lib = allBooks + .Select(lbe => lbe.LibraryBook) + .Where(lb => !lb.Book.HasLiberated()); + + var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.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; + + RemovableCountChanged?.Invoke(this, GetAllBookEntries().Count(lbe => lbe.Remove is true)); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + null, + "Error scanning library. You may still manually select books to remove from Libation's library.", + "Error scanning library", + ex); + } + } + + private void RemoveBooks(IEnumerable removedBooks) + { + //Remove books in series from their parents' Children list + foreach (var removed in removedBooks.Where(b => b.Parent is not null)) + { + removed.Parent.Children.Remove(removed); + + //In Avalonia, if you fire PropertyChanged with an empty or invalid property name, nothing is updated. + //So we must notify for specific properties that we believed changed. + removed.Parent.RaisePropertyChanged(nameof(SeriesEntrys2.Length)); + removed.Parent.RaisePropertyChanged(nameof(SeriesEntrys2.PurchaseDate)); + } + + //Remove series that have no children + var removedSeries = + bindingList + .AllItems() + .EmptySeries(); + + foreach (var removed in removedBooks.Cast().Concat(removedSeries)) + //no need to re-filter for removed books + bindingList.Remove(removed); + + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs new file mode 100644 index 00000000..9e4fe5b5 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs @@ -0,0 +1,65 @@ +using Avalonia.Controls; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_Sorting() { } + + + private void RegisterCustomColumnComparers() + { + + removeGVColumn.CustomSortComparer = new RowComparer(removeGVColumn); + liberateGVColumn.CustomSortComparer = new RowComparer(liberateGVColumn); + titleGVColumn.CustomSortComparer = new RowComparer(titleGVColumn); + authorsGVColumn.CustomSortComparer = new RowComparer(authorsGVColumn); + narratorsGVColumn.CustomSortComparer = new RowComparer(narratorsGVColumn); + lengthGVColumn.CustomSortComparer = new RowComparer(lengthGVColumn); + seriesGVColumn.CustomSortComparer = new RowComparer(seriesGVColumn); + descriptionGVColumn.CustomSortComparer = new RowComparer(descriptionGVColumn); + categoryGVColumn.CustomSortComparer = new RowComparer(categoryGVColumn); + productRatingGVColumn.CustomSortComparer = new RowComparer(productRatingGVColumn); + purchaseDateGVColumn.CustomSortComparer = new RowComparer(purchaseDateGVColumn); + myRatingGVColumn.CustomSortComparer = new RowComparer(myRatingGVColumn); + miscGVColumn.CustomSortComparer = new RowComparer(miscGVColumn); + tagAndDetailsGVColumn.CustomSortComparer = new RowComparer(tagAndDetailsGVColumn); + } + + private void ReSort() + { + if (CurrentSortColumn is null) + { + bindingList.InternalList.Sort((i1, i2) => i2.DateAdded.CompareTo(i1.DateAdded)); + bindingList.ResetCollection(); + } + else + { + CurrentSortColumn.Sort(((RowComparer)CurrentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); + } + } + + + + private DataGridColumn CurrentSortColumn; + + + private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e) + { + //Force the comparer to get the current sort order. We can't + //retrieve it from inside this event handler because Avalonia + //doesn't set the property until after this event. + var comparer = e.Column.CustomSortComparer as RowComparer; + comparer.SortDirection = null; + + CurrentSortColumn = e.Column; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml similarity index 96% rename from Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml rename to Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml index 50361033..82c68edd 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml @@ -5,11 +5,12 @@ xmlns:views="clr-namespace:LibationWinForms.AvaloniaUI.Views" xmlns:controls="clr-namespace:LibationWinForms.AvaloniaUI.Controls" mc:Ignorable="d" d:DesignWidth="1560" d:DesignHeight="400" - x:Class="LibationWinForms.AvaloniaUI.Views.ProductsDisplay2"> + x:Class="LibationWinForms.AvaloniaUI.Views.ProductsGrid.ProductsDisplay2"> - - - + + + + @@ -147,7 +148,7 @@ -