From 49c6b391fdce70d1f9dd9d95f68f59cfea9041c9 Mon Sep 17 00:00:00 2001 From: MBucari Date: Fri, 10 Mar 2023 19:37:42 -0700 Subject: [PATCH] WinForms and Avalonia now share all GridEntry view models --- .../Controls/DataGridCheckBoxColumnExt.cs | 8 +- .../Controls/DataGridContextMenus.cs | 6 +- .../Controls/DataGridMyRatingColumn.cs | 25 +- .../ViewModels/AvaloniaEntryStatus.cs | 37 ++ .../LibationAvalonia/ViewModels/BookTags.cs | 9 - .../LibationAvalonia/ViewModels/GridEntry.cs | 238 ------------- .../ViewModels/LibraryBookEntry.cs | 170 --------- .../ViewModels/ProductsDisplayViewModel.cs | 68 ++-- .../ViewModels/RowComparer.cs | 37 +- .../ViewModels/SeriesEntry.cs | 135 -------- .../Views/ProductsDisplay.axaml | 54 ++- .../Views/ProductsDisplay.axaml.cs | 89 ++--- .../GridView/EntryStatus.cs} | 96 ++++-- .../GridView/GridEntry[TStatus].cs | 324 ++++++++++++++++++ Source/LibationUiBase/GridView/IGridEntry.cs | 34 ++ .../GridView/ILibraryBookEntry.cs | 7 + .../LibationUiBase/GridView/ISeriesEntry.cs | 11 + .../{ => GridView}/LastDownloadStatus.cs | 2 +- .../GridView/LibraryBookEntry[TStatus].cs | 34 ++ .../{ => GridView}/ObjectComparer[T].cs | 2 +- .../GridView}/QueryExtensions.cs | 18 +- .../GridView/SeriesEntry[TStatus].cs | 68 ++++ Source/LibationUiBase/IcoEncoder.cs | 6 +- Source/LibationUiBase/SampleRateSelection.cs | 8 +- Source/LibationUiBase/TrackedQueue[T].cs | 4 +- Source/LibationUiBase/Upgrader.cs | 2 +- .../EditTagsDataGridViewImageButtonColumn.cs | 23 +- Source/LibationWinForms/GridView/GridEntry.cs | 233 ------------- .../GridView/GridEntryBindingList.cs | 31 +- .../GridView/LastDownloadedGridViewColumn.cs | 5 +- .../GridView/LiberateButtonStatus.cs | 38 -- .../LiberateDataGridViewImageButtonColumn.cs | 72 +--- .../GridView/LibraryBookEntry.cs | 177 ---------- .../GridView/MyRatingGridViewColumn.cs | 4 +- .../GridView/ProductsDisplay.cs | 23 +- .../GridView/ProductsGrid.Designer.cs | 15 +- .../LibationWinForms/GridView/ProductsGrid.cs | 210 ++++++------ .../GridView/QueryExtensions.cs | 44 --- .../LibationWinForms/GridView/SeriesEntry.cs | 133 ------- .../GridView/WinFormsEntryStatus.cs | 37 ++ 40 files changed, 930 insertions(+), 1607 deletions(-) create mode 100644 Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs delete mode 100644 Source/LibationAvalonia/ViewModels/BookTags.cs delete mode 100644 Source/LibationAvalonia/ViewModels/GridEntry.cs delete mode 100644 Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs delete mode 100644 Source/LibationAvalonia/ViewModels/SeriesEntry.cs rename Source/{LibationAvalonia/ViewModels/LiberateButtonStatus.cs => LibationUiBase/GridView/EntryStatus.cs} (50%) create mode 100644 Source/LibationUiBase/GridView/GridEntry[TStatus].cs create mode 100644 Source/LibationUiBase/GridView/IGridEntry.cs create mode 100644 Source/LibationUiBase/GridView/ILibraryBookEntry.cs create mode 100644 Source/LibationUiBase/GridView/ISeriesEntry.cs rename Source/LibationUiBase/{ => GridView}/LastDownloadStatus.cs (97%) create mode 100644 Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs rename Source/LibationUiBase/{ => GridView}/ObjectComparer[T].cs (84%) rename Source/{LibationAvalonia/ViewModels => LibationUiBase/GridView}/QueryExtensions.cs (59%) create mode 100644 Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs delete mode 100644 Source/LibationWinForms/GridView/GridEntry.cs delete mode 100644 Source/LibationWinForms/GridView/LiberateButtonStatus.cs delete mode 100644 Source/LibationWinForms/GridView/LibraryBookEntry.cs delete mode 100644 Source/LibationWinForms/GridView/QueryExtensions.cs delete mode 100644 Source/LibationWinForms/GridView/SeriesEntry.cs create mode 100644 Source/LibationWinForms/GridView/WinFormsEntryStatus.cs diff --git a/Source/LibationAvalonia/Controls/DataGridCheckBoxColumnExt.cs b/Source/LibationAvalonia/Controls/DataGridCheckBoxColumnExt.cs index 0bf914c9..de44df9a 100644 --- a/Source/LibationAvalonia/Controls/DataGridCheckBoxColumnExt.cs +++ b/Source/LibationAvalonia/Controls/DataGridCheckBoxColumnExt.cs @@ -1,17 +1,15 @@ using Avalonia.Controls; -using LibationAvalonia.ViewModels; -using System; -using System.Linq; +using LibationUiBase.GridView; namespace LibationAvalonia.Controls { - public class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn + public class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn { 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.IsThreeState = dataItem is SeriesEntry; + ele.IsThreeState = dataItem is ISeriesEntry; return ele; } } diff --git a/Source/LibationAvalonia/Controls/DataGridContextMenus.cs b/Source/LibationAvalonia/Controls/DataGridContextMenus.cs index 68660430..277e68ee 100644 --- a/Source/LibationAvalonia/Controls/DataGridContextMenus.cs +++ b/Source/LibationAvalonia/Controls/DataGridContextMenus.cs @@ -1,6 +1,6 @@ using Avalonia.Collections; using Avalonia.Controls; -using LibationAvalonia.ViewModels; +using LibationUiBase.GridView; using System; using System.Reflection; @@ -30,7 +30,7 @@ namespace LibationAvalonia.Controls private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e) { - if (sender is DataGridCell cell && cell.DataContext is GridEntry entry) + if (sender is DataGridCell cell && cell.DataContext is IGridEntry entry) { var args = new DataGridCellContextMenuStripNeededEventArgs { @@ -63,7 +63,7 @@ namespace LibationAvalonia.Controls public string CellClipboardContents => GetCellValue(Column, GridEntry); public DataGridColumn Column { get; init; } - public GridEntry GridEntry { get; init; } + public IGridEntry GridEntry { get; init; } public ContextMenu ContextMenu { get; init; } public AvaloniaList ContextMenuItems => ContextMenu.Items as AvaloniaList; diff --git a/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs b/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs index b563883e..5a655ceb 100644 --- a/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs +++ b/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs @@ -1,5 +1,6 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Data; using Avalonia.Interactivity; using DataLayer; using ReactiveUI; @@ -7,19 +8,10 @@ using System; namespace LibationAvalonia.Controls { - public class StarStringConverter : Avalonia.Data.Converters.IValueConverter - { - public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) - => value is Rating rating ? rating.ToStarString() : string.Empty; - - public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) - => throw new NotImplementedException(); - } - public class DataGridMyRatingColumn : DataGridBoundColumn { - [Avalonia.Data.AssignBinding] - public Avalonia.Data.IBinding BackgroundBinding { get; set; } + [AssignBinding] public IBinding BackgroundBinding { get; set; } + [AssignBinding] public IBinding OpacityBinding { get; set; } private static Rating DefaultRating => new Rating(0, 0, 0); public DataGridMyRatingColumn() { @@ -40,13 +32,11 @@ namespace LibationAvalonia.Controls ToolTip.SetTip(myRatingElement, "Click to change ratings"); if (Binding != null) - { myRatingElement.Bind(BindingTarget, Binding); - } if (BackgroundBinding != null) - { myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding); - } + if (OpacityBinding != null) + myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding); return myRatingElement; } @@ -58,10 +48,11 @@ namespace LibationAvalonia.Controls Name = "CellMyRatingEditor", IsEditingMode = true }; + if (BackgroundBinding != null) - { myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding); - } + if (OpacityBinding != null) + myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding); return myRatingElement; } diff --git a/Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs b/Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs new file mode 100644 index 00000000..e484206c --- /dev/null +++ b/Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs @@ -0,0 +1,37 @@ +using Avalonia.Media; +using Avalonia.Media.Imaging; +using DataLayer; +using LibationUiBase.GridView; +using System; + +namespace LibationAvalonia.ViewModels +{ + public class AvaloniaEntryStatus : EntryStatus, IEntryStatus, IComparable + { + private static Bitmap _defaultImage; + public override IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent; + + private AvaloniaEntryStatus(LibraryBook libraryBook) : base(libraryBook) { } + public static EntryStatus Create(LibraryBook libraryBook) => new AvaloniaEntryStatus(libraryBook); + + protected override Bitmap LoadImage(byte[] picture) + { + try + { + using var ms = new System.IO.MemoryStream(picture); + return new Bitmap(ms); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book); + return _defaultImage ??= new Bitmap(App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg")); + } + } + + protected override Bitmap GetResourceImage(string rescName) + { + using var stream = App.OpenAsset(rescName + ".png"); + return new Bitmap(stream); + } + } +} diff --git a/Source/LibationAvalonia/ViewModels/BookTags.cs b/Source/LibationAvalonia/ViewModels/BookTags.cs deleted file mode 100644 index 0f24941d..00000000 --- a/Source/LibationAvalonia/ViewModels/BookTags.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LibationAvalonia.ViewModels -{ - public class BookTags - { - private string _tags; - public string Tags { get => _tags; init { _tags = value; HasTags = !string.IsNullOrEmpty(_tags); } } - public bool HasTags { get; init; } - } -} diff --git a/Source/LibationAvalonia/ViewModels/GridEntry.cs b/Source/LibationAvalonia/ViewModels/GridEntry.cs deleted file mode 100644 index 2b989afd..00000000 --- a/Source/LibationAvalonia/ViewModels/GridEntry.cs +++ /dev/null @@ -1,238 +0,0 @@ -using ApplicationServices; -using Avalonia.Media; -using Avalonia.Media.Imaging; -using DataLayer; -using Dinah.Core; -using FileLiberator; -using LibationFileManager; -using LibationUiBase; -using ReactiveUI; -using System; -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; - -namespace LibationAvalonia.ViewModels -{ - public enum RemoveStatus - { - NotRemoved, - Removed, - SomeRemoved - } - /// The View Model base for the DataGridView - public abstract class GridEntry : ViewModelBase - { - [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; - [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } - [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)] public 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 LastDownloadStatus _lastDownload; - private string _description; - private Rating _productrating; - protected Rating _myRating; - - public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set => this.RaiseAndSetIfChanged(ref _cover, value); } - public string PurchaseDate { get => _purchasedate; protected set => this.RaiseAndSetIfChanged(ref _purchasedate, value); } - public string Series { get => _series; protected set => this.RaiseAndSetIfChanged(ref _series, value); } - public string Title { get => _title; protected set => this.RaiseAndSetIfChanged(ref _title, value); } - public string Length { get => _length; protected set => this.RaiseAndSetIfChanged(ref _length, value); } - public string Authors { get => _authors; protected set => this.RaiseAndSetIfChanged(ref _authors, value); } - public string Narrators { get => _narrators; protected set => this.RaiseAndSetIfChanged(ref _narrators, value); } - public string Category { get => _category; protected set => this.RaiseAndSetIfChanged(ref _category, value); } - public LastDownloadStatus LastDownload { get => _lastDownload; protected set => this.RaiseAndSetIfChanged(ref _lastDownload, value); } - public string Misc { get => _misc; protected set => this.RaiseAndSetIfChanged(ref _misc, value); } - public string Description { get => _description; protected set => this.RaiseAndSetIfChanged(ref _description, value); } - public Rating ProductRating { get => _productrating; protected set => this.RaiseAndSetIfChanged(ref _productrating, value); } - public Rating MyRating - { - get => _myRating; - set - { - if (_myRating != value - && value.OverallRating != 0 - && updateReviewTask?.IsCompleted is not false) - { - updateReviewTask = UpdateRating(value); - } - } - } - - protected bool? _remove = false; - public abstract bool? Remove { get; set; } - public abstract LiberateButtonStatus Liberate { get; } - public abstract BookTags BookTags { get; } - public abstract bool IsEpisode { get; } - public abstract bool IsBook { get; } - public IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent; - - #endregion - - #region User rating - - private Task updateReviewTask; - private async Task UpdateRating(Rating rating) - { - var api = await LibraryBook.GetApiAsync(); - - if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating)) - { - _myRating = rating; - LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating); - } - - this.RaisePropertyChanged(nameof(MyRating)); - } - - #endregion - - #region Sorting - - public GridEntry() => _memberValues = CreateMemberValueDictionary(); - - // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable - // Used by GridEntryBindingList for all sorting - public virtual object GetMemberValue(string memberName) => _memberValues[memberName](); - public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType]; - protected abstract Dictionary> CreateMemberValueDictionary(); - private Dictionary> _memberValues { get; set; } - - // Instantiate comparers for every exposed member object type. - private static readonly Dictionary _memberTypeComparers = new() - { - { typeof(RemoveStatus), new ObjectComparer() }, - { typeof(string), new ObjectComparer() }, - { typeof(int), new ObjectComparer() }, - { typeof(float), new ObjectComparer() }, - { typeof(bool), new ObjectComparer() }, - { typeof(DateTime), new ObjectComparer() }, - { typeof(LiberateButtonStatus), new ObjectComparer() }, - { typeof(LastDownloadStatus), new ObjectComparer() }, - }; - - #endregion - - #region Cover Art - - protected void LoadCover() - { - // Get cover art. If it's default, subscribe to PictureCached - (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); - - if (isDefault) - PictureStorage.PictureCached += PictureStorage_PictureCached; - - // Mutable property. Set the field so PropertyChanged isn't fired. - _cover = loadImage(picture); - } - - private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) - { - // state validation - if (e is null || - e.Definition.PictureId is null || - Book?.PictureId is null || - e.Picture is null || - e.Picture.Length == 0) - return; - - // logic validation - if (e.Definition.PictureId == Book.PictureId) - { - Cover = loadImage(e.Picture); - PictureStorage.PictureCached -= PictureStorage_PictureCached; - } - } - - private Bitmap loadImage(byte[] picture) - { - try - { - using var ms = new System.IO.MemoryStream(picture); - return new Bitmap(ms); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book); - return DefaultImage; - } - } - - private static Bitmap _defaultImage; - private static Bitmap DefaultImage => _defaultImage ??= new Bitmap(App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg")); - - #endregion - - #region Static library display functions - - /// This information should not change during lifetime, so call only once. - protected static string GetDescriptionDisplay(Book book) - { - var doc = new HtmlAgilityPack.HtmlDocument(); - doc.LoadHtml(book?.Description?.Replace("

", "\r\n\r\n

") ?? ""); - return doc.DocumentNode.InnerText.Trim(); - } - - protected static string TrimTextToWord(string text, int maxLength) - { - return - text.Length <= maxLength ? - text : - text.Substring(0, maxLength - 3) + "..."; - } - - - /// - /// This information should not change during lifetime, so call only once. - /// Maximum of 5 text rows will fit in 80-pixel row height. - /// - protected static string GetMiscDisplay(LibraryBook libraryBook) - { - var details = new List(); - - var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]"); - var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]"); - - details.Add($"Account: {locale} - {acct}"); - - if (libraryBook.Book.HasPdf()) - details.Add("Has PDF"); - if (libraryBook.Book.IsAbridged) - details.Add("Abridged"); - if (libraryBook.Book.DatePublished.HasValue) - details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}"); - // this goes last since it's most likely to have a line-break - if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher)) - details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}"); - - if (!details.Any()) - return "[details not imported]"; - - return string.Join("\r\n", details); - } - - #endregion - - ~GridEntry() - { - PictureStorage.PictureCached -= PictureStorage_PictureCached; - } - } -} diff --git a/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs b/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs deleted file mode 100644 index 20ac780f..00000000 --- a/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs +++ /dev/null @@ -1,170 +0,0 @@ -using ApplicationServices; -using DataLayer; -using Dinah.Core; -using ReactiveUI; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; - -namespace LibationAvalonia.ViewModels -{ - /// The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode - public class LibraryBookEntry : GridEntry - { - [Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded; - [Browsable(false)] public SeriesEntry Parent { get; init; } - - #region Model properties exposed to the view - - private DateTime lastStatusUpdate = default; - private LiberatedStatus _bookStatus; - private LiberatedStatus? _pdfStatus; - - public override bool? Remove - { - get => _remove; - set - { - _remove = value ?? false; - - Parent?.ChildRemoveUpdate(); - this.RaisePropertyChanged(nameof(Remove)); - } - } - - public override LiberateButtonStatus Liberate - { - get - { - //Cache these statuses for faster sorting. - if ((DateTime.Now - lastStatusUpdate).TotalSeconds > 2) - { - _bookStatus = LibraryCommands.Liberated_Status(LibraryBook.Book); - _pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book); - lastStatusUpdate = DateTime.Now; - } - return new LiberateButtonStatus(isSeries: false, LibraryBook.AbsentFromLastScan) { BookStatus = _bookStatus, PdfStatus = _pdfStatus }; - } - } - - public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) }; - - public override bool IsEpisode => Parent is not null; - public override bool IsBook => Parent is null; - - #endregion - - public LibraryBookEntry(LibraryBook libraryBook) - { - setLibraryBook(libraryBook); - LoadCover(); - } - - public void UpdateLibraryBook(LibraryBook libraryBook) - { - if (AudibleProductId != libraryBook.Book.AudibleProductId) - throw new Exception("Invalid grid entry update. IDs must match"); - - UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; - setLibraryBook(libraryBook); - } - - private void setLibraryBook(LibraryBook libraryBook) - { - LibraryBook = libraryBook; - - Title = Book.Title; - Series = Book.SeriesNames(); - Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min"; - //Ratings are changed using Update(), which is a problem for Avalonia data bindings because - //the reference doesn't change. Clone the rating so that it updates within Avalonia properly. - _myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating); - PurchaseDate = libraryBook.DateAdded.ToString("d"); - ProductRating = Book.Rating ?? new Rating(0, 0, 0); - Authors = Book.AuthorNames(); - Narrators = Book.NarratorNames(); - Category = string.Join(" > ", Book.CategoriesNames()); - Misc = GetMiscDisplay(libraryBook); - LastDownload = new(Book.UserDefinedItem); - LongDescription = GetDescriptionDisplay(Book); - Description = TrimTextToWord(LongDescription, 62); - SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; - - this.RaisePropertyChanged(nameof(MyRating)); - this.RaisePropertyChanged(nameof(Liberate)); - UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; - } - - #region detect changes to the model, update the view. - - /// - /// This event handler receives notifications from the model that it has changed. - /// Notify the view that it's changed. - /// - private void UserDefinedItem_ItemChanged(object sender, string itemName) - { - var udi = sender as UserDefinedItem; - - if (udi.Book.AudibleProductId != Book.AudibleProductId) - return; - - // UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view. - // - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs - // - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view. - switch (itemName) - { - case nameof(udi.Tags): - Book.UserDefinedItem.Tags = udi.Tags; - this.RaisePropertyChanged(nameof(BookTags)); - break; - case nameof(udi.BookStatus): - Book.UserDefinedItem.BookStatus = udi.BookStatus; - _bookStatus = udi.BookStatus; - this.RaisePropertyChanged(nameof(Liberate)); - break; - case nameof(udi.PdfStatus): - Book.UserDefinedItem.SetPdfStatus(udi.PdfStatus); - _pdfStatus = udi.PdfStatus; - this.RaisePropertyChanged(nameof(Liberate)); - break; - case nameof(udi.LastDownloaded): - LastDownload = new(udi); - this.RaisePropertyChanged(nameof(LastDownload)); - break; - } - } - - #endregion - - #region Data Sorting - - /// Create getters for all member object values by name - protected override Dictionary> CreateMemberValueDictionary() => new() - { - { nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved }, - { nameof(Title), () => Book.TitleSortable() }, - { nameof(Series), () => Book.SeriesSortable() }, - { nameof(Length), () => Book.LengthInMinutes }, - { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, - { nameof(PurchaseDate), () => LibraryBook.DateAdded }, - { nameof(ProductRating), () => Book.Rating.FirstScore() }, - { nameof(Authors), () => Authors }, - { nameof(Narrators), () => Narrators }, - { nameof(Description), () => Description }, - { nameof(Category), () => Category }, - { nameof(Misc), () => Misc }, - { nameof(LastDownload), () => LastDownload }, - { nameof(BookTags), () => BookTags?.Tags ?? string.Empty }, - { nameof(Liberate), () => Liberate }, - { nameof(DateAdded), () => DateAdded }, - }; - - #endregion - - ~LibraryBookEntry() - { - UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; - } - } -} diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 601b53fc..8307a48b 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -1,15 +1,16 @@ +using ApplicationServices; +using AudibleUtilities; +using Avalonia.Collections; +using Avalonia.Threading; using DataLayer; +using LibationAvalonia.Dialogs.Login; +using LibationUiBase.GridView; +using ReactiveUI; using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Threading.Tasks; -using ReactiveUI; -using Avalonia.Threading; -using ApplicationServices; -using AudibleUtilities; -using LibationAvalonia.Dialogs.Login; -using Avalonia.Collections; namespace LibationAvalonia.ViewModels { @@ -20,9 +21,9 @@ namespace LibationAvalonia.ViewModels public event EventHandler RemovableCountChanged; /// Backing list of all grid entries - private readonly AvaloniaList SOURCE = new(); + private readonly AvaloniaList SOURCE = new(); /// Grid entries included in the filter set. If null, all grid entries are shown - private List FilteredInGridEntries; + private List FilteredInGridEntries; public string FilterString { get; private set; } public DataGridCollectionView GridEntries { get; private set; } @@ -31,11 +32,11 @@ namespace LibationAvalonia.ViewModels public List GetVisibleBookEntries() => GridEntries - .OfType() + .OfType() .Select(lbe => lbe.LibraryBook) .ToList(); - private IEnumerable GetAllBookEntries() + private IEnumerable GetAllBookEntries() => SOURCE .BookEntries(); @@ -92,11 +93,10 @@ namespace LibationAvalonia.ViewModels var geList = dbBooks .Where(lb => lb.Book.IsProduct()) - .Select(b => new LibraryBookEntry(b)) - .Cast() - .ToList(); + .Select(b => new LibraryBookEntry(b)) + .ToList(); - var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()); + var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()).ToList(); var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList(); @@ -106,7 +106,7 @@ namespace LibationAvalonia.ViewModels if (!seriesEpisodes.Any()) continue; - var seriesEntry = new SeriesEntry(parent, seriesEpisodes); + var seriesEntry = new SeriesEntry(parent, seriesEpisodes); seriesEntry.Liberate.Expanded = false; geList.Add(seriesEntry); @@ -116,9 +116,10 @@ namespace LibationAvalonia.ViewModels //Create the filtered-in list before adding entries to avoid a refresh FilteredInGridEntries = QueryResults(geList, FilterString); SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded)); - VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); GridEntries.CollectionChanged += (_, _) - => VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); + => VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); + + VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); } /// @@ -129,7 +130,7 @@ namespace LibationAvalonia.ViewModels #region Add new or update existing grid entries //Add absent entries to grid, or update existing entry - var allEntries = SOURCE.BookEntries(); + var allEntries = SOURCE.BookEntries().ToList(); var seriesEntries = SOURCE.SeriesEntries().ToList(); var parentedEpisodes = dbBooks.ParentedEpisodes().ToList(); @@ -163,7 +164,7 @@ namespace LibationAvalonia.ViewModels .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); //Remove books in series from their parents' Children list - foreach (var removed in removedBooks.Where(b => b.Parent is not null)) + foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode)) removed.Parent.RemoveChild(removed); //Remove series that have no children @@ -174,11 +175,12 @@ namespace LibationAvalonia.ViewModels #endregion await Filter(FilterString); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true); } - private void RemoveBooks(IEnumerable removedBooks, IEnumerable removedSeries) + private void RemoveBooks(IEnumerable removedBooks, IEnumerable removedSeries) { - foreach (var removed in removedBooks.Cast().Concat(removedSeries).Where(b => b is not null).ToList()) + foreach (var removed in removedBooks.Cast().Concat(removedSeries).Where(b => b is not null).ToList()) { if (GridEntries.PassesFilter(removed)) GridEntries.Remove(removed); @@ -191,21 +193,21 @@ namespace LibationAvalonia.ViewModels } } - private void UpsertBook(LibraryBook book, LibraryBookEntry existingBookEntry) + private void UpsertBook(LibraryBook book, ILibraryBookEntry existingBookEntry) { if (existingBookEntry is null) // Add the new product to top - SOURCE.Insert(0, new LibraryBookEntry(book)); + SOURCE.Insert(0, new LibraryBookEntry(book)); else // update existing existingBookEntry.UpdateLibraryBook(book); } - private void UpsertEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) + private void UpsertEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) { if (existingEpisodeEntry is null) { - LibraryBookEntry episodeEntry; + ILibraryBookEntry episodeEntry; var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); @@ -223,7 +225,7 @@ namespace LibationAvalonia.ViewModels return; } - seriesEntry = new SeriesEntry(seriesBook, new[] { episodeBook }); + seriesEntry = new SeriesEntry(seriesBook, episodeBook); seriesEntries.Add(seriesEntry); episodeEntry = seriesEntry.Children[0]; @@ -233,7 +235,7 @@ namespace LibationAvalonia.ViewModels else { //Series exists. Create and add episode child then update the SeriesEntry - episodeEntry = new(episodeBook) { Parent = seriesEntry }; + episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry); seriesEntry.Children.Add(episodeEntry); var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); seriesEntry.UpdateLibraryBook(seriesBook); @@ -255,7 +257,7 @@ namespace LibationAvalonia.ViewModels await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh); } - public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry) + public async Task ToggleSeriesExpanded(ISeriesEntry seriesEntry) { seriesEntry.Liberate.Expanded = !seriesEntry.Liberate.Expanded; @@ -280,8 +282,8 @@ namespace LibationAvalonia.ViewModels private bool CollectionFilter(object item) { - if (item is LibraryBookEntry lbe - && lbe.IsEpisode + if (item is ILibraryBookEntry lbe + && lbe.Liberate.IsEpisode && lbe.Parent?.Liberate?.Expanded != true) return false; @@ -290,13 +292,13 @@ namespace LibationAvalonia.ViewModels return FilteredInGridEntries.Contains(item); } - private static List QueryResults(IEnumerable entries, string searchString) + private static List QueryResults(IEnumerable entries, string searchString) { if (string.IsNullOrEmpty(searchString)) return null; var searchResultSet = SearchEngineCommands.Search(searchString); - var booksFilteredIn = entries.BookEntries().Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe); + var booksFilteredIn = entries.BookEntries().Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe); //Find all series containing children that match the search criteria var seriesFilteredIn = entries.SeriesEntries().Where(s => s.Children.Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); @@ -411,7 +413,7 @@ namespace LibationAvalonia.ViewModels private void GridEntry_PropertyChanged(object sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(GridEntry.Remove) && sender is LibraryBookEntry lbEntry) + if (e.PropertyName == nameof(IGridEntry.Remove) && sender is ILibraryBookEntry) { int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true); RemovableCountChanged?.Invoke(this, removeCount); diff --git a/Source/LibationAvalonia/ViewModels/RowComparer.cs b/Source/LibationAvalonia/ViewModels/RowComparer.cs index 71a912ee..b20dc5de 100644 --- a/Source/LibationAvalonia/ViewModels/RowComparer.cs +++ b/Source/LibationAvalonia/ViewModels/RowComparer.cs @@ -1,5 +1,5 @@ using Avalonia.Controls; -using System; +using LibationUiBase.GridView; using System.Collections; using System.Collections.Generic; using System.ComponentModel; @@ -13,7 +13,7 @@ namespace LibationAvalonia.ViewModels /// 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, IComparer + internal class RowComparer : IComparer, IComparer, IComparer { 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); @@ -33,22 +33,22 @@ namespace LibationAvalonia.ViewModels if (x is not null && y is null) return 1; if (x is null && y is null) return 0; - var geA = (GridEntry)x; - var geB = (GridEntry)y; + var geA = (IGridEntry)x; + var geB = (IGridEntry)y; var sortDirection = GetSortOrder(); - SeriesEntry parentA = null; - SeriesEntry parentB = null; + ISeriesEntry parentA = null; + ISeriesEntry parentB = null; - if (geA is LibraryBookEntry lbA && lbA.Parent is SeriesEntry seA) + if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA) parentA = seA; - if (geB is LibraryBookEntry lbB && lbB.Parent is SeriesEntry seB) + if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry seB) parentB = seB; //both a and b are top-level grid entries if (parentA is null && parentB is null) - return InternalCompare(geA, geB, sortDirection); + return InternalCompare(geA, geB); //a is top-level, b is a child if (parentA is null && parentB is not null) @@ -57,7 +57,7 @@ namespace LibationAvalonia.ViewModels if (parentB == geA) return sortDirection is ListSortDirection.Ascending ? -1 : 1; else - return InternalCompare(geA, parentB, sortDirection); + return InternalCompare(geA, parentB); } //a is a child, b is a top-level @@ -67,7 +67,7 @@ namespace LibationAvalonia.ViewModels if (parentA == geB) return sortDirection is ListSortDirection.Ascending ? 1 : -1; else - return InternalCompare(parentA, geB, sortDirection); + return InternalCompare(parentA, geB); } //both are children of the same series, always present in order of series index, ascending @@ -75,29 +75,22 @@ namespace LibationAvalonia.ViewModels return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (sortDirection is ListSortDirection.Ascending ? 1 : -1); //a and b are children of different series. - return InternalCompare(parentA, parentB, sortDirection); + return InternalCompare(parentA, parentB); } //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection private ListSortDirection? GetSortOrder() => CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?; - private int InternalCompare(GridEntry x, GridEntry y, ListSortDirection? sortDirection) + private int InternalCompare(IGridEntry x, IGridEntry y) { var val1 = x.GetMemberValue(PropertyName); var val2 = y.GetMemberValue(PropertyName); - var compareResult = x.GetMemberComparer(val1.GetType()).Compare(val1, val2); - - //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) * (sortDirection is ListSortDirection.Ascending ? 1 : -1); - else - return compareResult; + return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); ; } - public int Compare(GridEntry x, GridEntry y) + public int Compare(IGridEntry x, IGridEntry y) { return Compare((object)x, y); } diff --git a/Source/LibationAvalonia/ViewModels/SeriesEntry.cs b/Source/LibationAvalonia/ViewModels/SeriesEntry.cs deleted file mode 100644 index 5fe5464d..00000000 --- a/Source/LibationAvalonia/ViewModels/SeriesEntry.cs +++ /dev/null @@ -1,135 +0,0 @@ -using DataLayer; -using Dinah.Core; -using ReactiveUI; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; - -namespace LibationAvalonia.ViewModels -{ - /// The View Model for a LibraryBook that is ContentType.Parent - public class SeriesEntry : GridEntry - { - [Browsable(false)] public List Children { get; } - [Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded); - - private bool suspendCounting = false; - public void ChildRemoveUpdate() - { - if (suspendCounting) return; - - var removeCount = Children.Count(c => c.Remove == true); - - _remove = removeCount == 0 ? false : (removeCount == Children.Count ? true : null); - this.RaisePropertyChanged(nameof(Remove)); - } - - #region Model properties exposed to the view - public override bool? Remove - { - get => _remove; - set - { - _remove = value ?? false; - - suspendCounting = true; - - foreach (var item in Children) - item.Remove = value; - - suspendCounting = false; - this.RaisePropertyChanged(nameof(Remove)); - } - } - - public override LiberateButtonStatus Liberate { get; } - public override BookTags BookTags { get; } = new(); - - public override bool IsEpisode => false; - public override bool IsBook => false; - - #endregion - - public SeriesEntry(LibraryBook parent, IEnumerable children) - { - Liberate = new LiberateButtonStatus(isSeries: true, isAbsent: false); - SeriesIndex = -1; - - Children = children - .Select(c => new LibraryBookEntry(c) { Parent = this }) - .OrderBy(c => c.SeriesIndex) - .ToList(); - - setLibraryBook(parent); - LoadCover(); - } - - public void RemoveChild(LibraryBookEntry lbe) - { - Children.Remove(lbe); - PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); - int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); - Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; - } - - public void UpdateLibraryBook(LibraryBook libraryBook) - { - if (AudibleProductId != libraryBook.Book.AudibleProductId) - throw new Exception("Invalid grid entry update. IDs must match"); - - setLibraryBook(libraryBook); - } - - private void setLibraryBook(LibraryBook libraryBook) - { - LibraryBook = libraryBook; - - Title = Book.Title; - Series = Book.SeriesNames(); - //Ratings are changed using Update(), which is a problem for Avalonia data bindings because - //the reference doesn't change. Clone the rating so that it updates within Avalonia properly. - _myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating); - ProductRating = Book.Rating ?? new Rating(0, 0, 0); - Authors = Book.AuthorNames(); - Narrators = Book.NarratorNames(); - Category = string.Join(" > ", Book.CategoriesNames()); - Misc = GetMiscDisplay(LibraryBook); - LastDownload = new(); - 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"; - - this.RaisePropertyChanged(nameof(MyRating)); - } - - - #region Data Sorting - - /// Create getters for all member object values by name - protected override Dictionary> CreateMemberValueDictionary() => new() - { - { nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved }, - { nameof(Title), () => Book.TitleSortable() }, - { nameof(Series), () => Book.SeriesSortable() }, - { nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) }, - { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, - { nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) }, - { nameof(ProductRating), () => Book.Rating.FirstScore() }, - { nameof(Authors), () => Authors }, - { nameof(Narrators), () => Narrators }, - { nameof(Description), () => Description }, - { nameof(Category), () => Category }, - { nameof(Misc), () => Misc }, - { nameof(LastDownload), () => LastDownload }, - { nameof(BookTags), () => BookTags?.Tags ?? string.Empty }, - { nameof(Liberate), () => Liberate }, - { nameof(DateAdded), () => DateAdded }, - }; - - #endregion - } -} diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml b/Source/LibationAvalonia/Views/ProductsDisplay.axaml index 20e2f00e..1af04f29 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml @@ -32,11 +32,7 @@ - - - - - + - - + @@ -75,7 +71,7 @@ - + @@ -83,7 +79,7 @@ - + @@ -93,7 +89,7 @@ - + @@ -103,7 +99,7 @@ - + @@ -113,7 +109,7 @@ - + @@ -123,7 +119,7 @@ - + @@ -133,7 +129,7 @@ - + @@ -143,7 +139,7 @@ - + @@ -155,14 +151,15 @@ IsReadOnly="true" Width="115" SortMemberPath="ProductRating" CanUserSort="True" - BackgroundBinding="{Binding BackgroundBrush}" - ClipboardContentBinding="{Binding ProductRating, Converter={StaticResource starStringConverter}}" + OpacityBinding="{Binding Liberate.Opacity}" + BackgroundBinding="{Binding Liberate.BackgroundBrush}" + ClipboardContentBinding="{Binding ProductRating}" Binding="{Binding ProductRating}" /> - + @@ -174,14 +171,15 @@ IsReadOnly="false" Width="115" SortMemberPath="MyRating" CanUserSort="True" - BackgroundBinding="{Binding BackgroundBrush}" - ClipboardContentBinding="{Binding MyRating, Converter={StaticResource starStringConverter}}" + OpacityBinding="{Binding Liberate.Opacity}" + BackgroundBinding="{Binding Liberate.BackgroundBrush}" + ClipboardContentBinding="{Binding MyRating}" Binding="{Binding MyRating, Mode=TwoWay}" /> - + @@ -191,20 +189,20 @@ - + - + - diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 3e9f866a..1461e6d7 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using ApplicationServices; using Avalonia; using Avalonia.Controls; @@ -13,6 +9,11 @@ using LibationAvalonia.Controls; using LibationAvalonia.Dialogs; using LibationAvalonia.ViewModels; using LibationFileManager; +using LibationUiBase.GridView; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; namespace LibationAvalonia.Views { @@ -61,7 +62,7 @@ namespace LibationAvalonia.Views { column.CustomSortComparer = new RowComparer(column); } - } + } private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) { @@ -83,37 +84,37 @@ namespace LibationAvalonia.Views #region Cell Context Menu public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args) - { - // stop light - if (args.Column.SortMemberPath == "Liberate") + { + // stop light + if (args.Column.SortMemberPath == "Liberate") { var entry = args.GridEntry; - if (entry.Liberate.IsSeries) + if (entry.Liberate.IsSeries) return; - var setDownloadMenuItem = new MenuItem() - { - Header = "Set Download status to '_Downloaded'", - IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated - }; - setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated); + var setDownloadMenuItem = new MenuItem() + { + Header = "Set Download status to '_Downloaded'", + IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated + }; + setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated); - var setNotDownloadMenuItem = new MenuItem() - { - Header = "Set Download status to '_Not Downloaded'", - IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated - }; - setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); + var setNotDownloadMenuItem = new MenuItem() + { + Header = "Set Download status to '_Not Downloaded'", + IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated + }; + setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); - var removeMenuItem = new MenuItem() { Header = "_Remove from library" }; + var removeMenuItem = new MenuItem() { Header = "_Remove from library" }; removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook); var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." }; - locateFileMenuItem.Click += async (_, __) => - { - try - { + locateFileMenuItem.Click += async (_, __) => + { + try + { var openFileDialogOptions = new FilePickerOpenOptions { Title = $"Locate the audio file for '{entry.Book.Title}'", @@ -128,19 +129,19 @@ namespace LibationAvalonia.Views var selectedFiles = await this.GetParentWindow().StorageProvider.OpenFilePickerAsync(openFileDialogOptions); var selectedFile = selectedFiles.SingleOrDefault(); - if (selectedFile?.TryGetUri(out var uri) is true) - FilePathCache.Insert(entry.AudibleProductId, uri.LocalPath); - } - catch (Exception ex) - { - var msg = "Error saving book's location"; - await MessageBox.ShowAdminAlert(null, msg, msg, ex); - } - }; + if (selectedFile?.TryGetUri(out var uri) is true) + FilePathCache.Insert(entry.AudibleProductId, uri.LocalPath); + } + catch (Exception ex) + { + var msg = "Error saving book's location"; + await MessageBox.ShowAdminAlert(null, msg, msg, ex); + } + }; var convertToMp3MenuItem = new MenuItem { Header = "_Convert to Mp3", - IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated + IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated }; convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook); @@ -157,7 +158,7 @@ namespace LibationAvalonia.Views new Separator(), bookRecordMenuItem }); - } + } else { // any non-stop light column @@ -200,7 +201,7 @@ namespace LibationAvalonia.Views { var itemName = column.SortMemberPath; - if (itemName == nameof(GridEntry.Remove)) + if (itemName == nameof(IGridEntry.Remove)) continue; menuItems.Add @@ -291,7 +292,7 @@ namespace LibationAvalonia.Views { var button = args.Source as Button; - if (button.DataContext is SeriesEntry sEntry) + if (button.DataContext is ISeriesEntry sEntry) { await _viewModel.ToggleSeriesExpanded(sEntry); @@ -299,7 +300,7 @@ namespace LibationAvalonia.Views //to the topright cell. Reset focus onto the clicked button's cell. (sender as Button).Parent?.Focus(); } - else if (button.DataContext is LibraryBookEntry lbEntry) + else if (button.DataContext is ILibraryBookEntry lbEntry) { LiberateClicked?.Invoke(this, lbEntry.LibraryBook); } @@ -313,13 +314,13 @@ namespace LibationAvalonia.Views public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args) { - if (sender is Control panel && panel.DataContext is LibraryBookEntry lbe && lbe.LastDownload.IsValid) + if (sender is Control panel && panel.DataContext is ILibraryBookEntry lbe && lbe.LastDownload.IsValid) lbe.LastDownload.OpenReleaseUrl(); } public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) { - if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry) + if (sender is not Image tblock || tblock.DataContext is not IGridEntry gEntry) return; if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible) @@ -358,7 +359,7 @@ namespace LibationAvalonia.Views public void Description_Click(object sender, Avalonia.Input.TappedEventArgs args) { - if (sender is Control tblock && tblock.DataContext is GridEntry gEntry) + if (sender is Control tblock && tblock.DataContext is IGridEntry gEntry) { var pt = tblock.PointToScreen(tblock.Bounds.TopRight); var displayWindow = new DescriptionDisplayDialog @@ -387,7 +388,7 @@ namespace LibationAvalonia.Views { var button = args.Source as Button; - if (button.DataContext is LibraryBookEntry lbEntry && VisualRoot is Window window) + if (button.DataContext is ILibraryBookEntry lbEntry && VisualRoot is Window window) { if (bookDetailsForm is null || !bookDetailsForm.IsVisible) { diff --git a/Source/LibationAvalonia/ViewModels/LiberateButtonStatus.cs b/Source/LibationUiBase/GridView/EntryStatus.cs similarity index 50% rename from Source/LibationAvalonia/ViewModels/LiberateButtonStatus.cs rename to Source/LibationUiBase/GridView/EntryStatus.cs index 6cccd411..e96ae76c 100644 --- a/Source/LibationAvalonia/ViewModels/LiberateButtonStatus.cs +++ b/Source/LibationUiBase/GridView/EntryStatus.cs @@ -1,45 +1,79 @@ -using Avalonia.Media.Imaging; +using ApplicationServices; using DataLayer; -using ReactiveUI; +using Dinah.Core; +using Dinah.Core.Threading; using System; using System.Collections.Generic; +using System.ComponentModel; -namespace LibationAvalonia.ViewModels +namespace LibationUiBase.GridView { - public class LiberateButtonStatus : ViewModelBase, IComparable + public interface IEntryStatus { - public LiberateButtonStatus(bool isSeries, bool isAbsent) - { - IsSeries = isSeries; - IsAbsent = isAbsent; - } - public LiberatedStatus BookStatus { get; set; } - public LiberatedStatus? PdfStatus { get; set; } + static abstract EntryStatus Create(LibraryBook libraryBook); + } - private bool _expanded; - public bool Expanded + //This Class holds all book entry status info to help the grid properly render the items. It + public abstract class EntryStatus : SynchronizeInvoker, IComparable, INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book); + public LiberatedStatus BookStatus { - get => _expanded; - set + get { - this.RaiseAndSetIfChanged(ref _expanded, value); - this.RaisePropertyChanged(nameof(Image)); - this.RaisePropertyChanged(nameof(ToolTip)); + if (IsSeries) return default; + + if ((DateTime.Now - lastBookUpdate).TotalSeconds > 2) + { + bookStatus = LibraryCommands.Liberated_Status(Book); + lastBookUpdate = DateTime.Now; + } + + return bookStatus; } } - private bool IsAbsent { get; } + public bool Expanded { get; set; } public bool IsSeries { get; } - public bool IsUnavailable => !IsSeries & IsAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated); - public Bitmap Image => GetLiberateIcon(); + public bool IsEpisode { get; } + public bool IsBook => !IsSeries && !IsEpisode; + public bool IsUnavailable => !IsSeries & isAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated); + public double Opacity => !IsSeries && Book.UserDefinedItem.Tags.ContainsInsensitive("hidden") ? 0.4 : 1; + public abstract object BackgroundBrush { get; } + public object ButtonImage => GetLiberateIcon(); public string ToolTip => GetTooltip(); + protected Book Book { get; } + + private DateTime lastBookUpdate; + private LiberatedStatus bookStatus; + private bool isAbsent; + private static readonly Dictionary iconCache = new(); + + protected EntryStatus(LibraryBook libraryBook) + { + Book = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)).Book; + isAbsent = libraryBook.AbsentFromLastScan is true; + IsEpisode = Book.ContentType is ContentType.Episode; + IsSeries = Book.ContentType is ContentType.Parent; + } + + internal protected abstract object LoadImage(byte[] picture); + protected abstract object GetResourceImage(string rescName); + public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args)); + public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName)); + public void Invalidate(params string[] properties) + { + lastBookUpdate = default; + foreach (var property in properties) + RaisePropertyChanged(property); + } - static Dictionary iconCache = new(); /// Defines the Liberate column's sorting behavior public int CompareTo(object obj) { - if (obj is not LiberateButtonStatus second) return -1; + if (obj is not EntryStatus second) return -1; if (IsSeries && !second.IsSeries) return -1; else if (!IsSeries && second.IsSeries) return 1; @@ -51,13 +85,13 @@ namespace LibationAvalonia.ViewModels else return BookStatus.CompareTo(second.BookStatus); } - private Bitmap GetLiberateIcon() + private object GetLiberateIcon() { if (IsSeries) - return Expanded ? GetFromResources("minus") : GetFromResources("plus"); + return Expanded ? GetAndCacheResource("minus") : GetAndCacheResource("plus"); if (BookStatus == LiberatedStatus.Error) - return GetFromResources("error"); + return GetAndCacheResource("error"); string image_lib = BookStatus switch { @@ -76,7 +110,7 @@ namespace LibationAvalonia.ViewModels _ => throw new Exception("Unexpected PDF state") }; - return GetFromResources($"liberate_{image_lib}{image_pdf}"); + return GetAndCacheResource($"liberate_{image_lib}{image_pdf}"); } private string GetTooltip() @@ -107,7 +141,6 @@ namespace LibationAvalonia.ViewModels _ => throw new Exception("Unexpected PDF state") }; - var mouseoverText = libState + pdfState; if (BookStatus == LiberatedStatus.NotLiberated || @@ -118,11 +151,10 @@ namespace LibationAvalonia.ViewModels return mouseoverText; } - private static Bitmap GetFromResources(string rescName) + private object GetAndCacheResource(string rescName) { - if (iconCache.ContainsKey(rescName)) return iconCache[rescName]; - - iconCache[rescName] = new Bitmap(App.OpenAsset(rescName + ".png")); + if (!iconCache.ContainsKey(rescName)) + iconCache[rescName] = GetResourceImage(rescName); return iconCache[rescName]; } } diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs new file mode 100644 index 00000000..0502b3f6 --- /dev/null +++ b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs @@ -0,0 +1,324 @@ +using ApplicationServices; +using DataLayer; +using Dinah.Core; +using Dinah.Core.Threading; +using FileLiberator; +using LibationFileManager; +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace LibationUiBase.GridView +{ + public enum RemoveStatus + { + NotRemoved, + Removed, + SomeRemoved + } + + /// The View Model base for the DataGridView + public abstract class GridEntry : SynchronizeInvoker, IGridEntry where TStatus : IEntryStatus + { + [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; + [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } + [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 Book Book => LibraryBook.Book; + + #region Model properties exposed to the view + + protected bool? remove = false; + private string _purchasedate; + private string _length; + private LastDownloadStatus _lastDownload; + private object _cover; + private string _series; + private string _title; + private string _authors; + private string _narrators; + private string _category; + private string _misc; + private string _description; + private Rating _productrating; + private string _bookTags; + private Rating _myRating; + + public abstract bool? Remove { get; set; } + public EntryStatus Liberate { get; private set; } + public string PurchaseDate { get => _purchasedate; protected set => RaiseAndSetIfChanged(ref _purchasedate, value); } + public string Length { get => _length; protected set => RaiseAndSetIfChanged(ref _length, value); } + public LastDownloadStatus LastDownload { get => _lastDownload; protected set => RaiseAndSetIfChanged(ref _lastDownload, value); } + public object Cover { get => _cover; private set => RaiseAndSetIfChanged(ref _cover, value); } + public string Series { get => _series; private set => RaiseAndSetIfChanged(ref _series, value); } + public string Title { get => _title; private set => RaiseAndSetIfChanged(ref _title, value); } + public string Authors { get => _authors; private set => RaiseAndSetIfChanged(ref _authors, value); } + public string Narrators { get => _narrators; private set => RaiseAndSetIfChanged(ref _narrators, value); } + public string Category { get => _category; private set => RaiseAndSetIfChanged(ref _category, value); } + public string Misc { get => _misc; private set => RaiseAndSetIfChanged(ref _misc, value); } + public string Description { get => _description; private set => RaiseAndSetIfChanged(ref _description, value); } + public Rating ProductRating { get => _productrating; private set => RaiseAndSetIfChanged(ref _productrating, value); } + public string BookTags { get => _bookTags; private set => RaiseAndSetIfChanged(ref _bookTags, value); } + + public Rating MyRating + { + get => _myRating; + set + { + if (_myRating != value && value.OverallRating != 0 && updateReviewTask?.IsCompleted is not false) + updateReviewTask = UpdateRating(value); + } + } + + #endregion + + #region User rating + + private Task updateReviewTask; + private async Task UpdateRating(Rating rating) + { + var api = await LibraryBook.GetApiAsync(); + + if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating)) + LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating); + } + + #endregion + + #region View property updating + + public void UpdateLibraryBook(LibraryBook libraryBook) + { + UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; + + LibraryBook = libraryBook; + + Liberate = TStatus.Create(libraryBook); + Title = Book.Title; + Series = Book.SeriesNames(); + Length = GetBookLengthString(); + //Ratings are changed using Update(), which is a problem for Avalonia data bindings because + //the reference doesn't change. Clone the rating so that it updates within Avalonia properly. + _myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating); + PurchaseDate = GetPurchaseDateString(); + ProductRating = Book.Rating ?? new Rating(0, 0, 0); + Authors = Book.AuthorNames(); + Narrators = Book.NarratorNames(); + Category = string.Join(" > ", Book.CategoriesNames()); + Misc = GetMiscDisplay(libraryBook); + LastDownload = new(Book.UserDefinedItem); + LongDescription = GetDescriptionDisplay(Book); + Description = TrimTextToWord(LongDescription, 62); + SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; + BookTags = GetBookTags(); + + RaisePropertyChanged(nameof(MyRating)); + + UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; + } + + protected abstract string GetBookTags(); + protected virtual DateTime GetPurchaseDate() => LibraryBook.DateAdded; + protected virtual int GetLengthInMinutes() => Book.LengthInMinutes; + protected string GetPurchaseDateString() => GetPurchaseDate().ToString("d"); + protected string GetBookLengthString() + { + int bookLenMins = GetLengthInMinutes(); + return bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; + } + + #endregion + + #region detect changes to the model, update the view. + + /// + /// This event handler receives notifications from the model that it has changed. + /// Notify the view that it's changed. + /// + private void UserDefinedItem_ItemChanged(object sender, string itemName) + { + var udi = sender as UserDefinedItem; + + if (udi.Book.AudibleProductId != Book.AudibleProductId) + return; + + // UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view. + // - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs + // - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view. + switch (itemName) + { + case nameof(udi.BookStatus): + case nameof(udi.PdfStatus): + Liberate.Invalidate(nameof(Liberate.BookStatus), nameof(Liberate.PdfStatus), nameof(Liberate.IsUnavailable), nameof(Liberate.ButtonImage), nameof(Liberate.ToolTip)); + RaisePropertyChanged(nameof(Liberate)); + break; + case nameof(udi.Tags): + BookTags = GetBookTags(); + Liberate.Invalidate(nameof(Liberate.Opacity)); + RaisePropertyChanged(nameof(Liberate)); + break; + case nameof(udi.LastDownloaded): + LastDownload = new (udi); + break; + case nameof(udi.Rating): + _myRating = udi.Rating; + RaisePropertyChanged(nameof(MyRating)); + break; + } + } + + private TRet RaiseAndSetIfChanged(ref TRet backingField, TRet newValue, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(backingField, newValue)) return newValue; + + backingField = newValue; + RaisePropertyChanged(new PropertyChangedEventArgs(propertyName)); + return newValue; + } + + public event PropertyChangedEventHandler PropertyChanged; + public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args)); + public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName)); + + #endregion + + #region Sorting + + public GridEntry() + { + memberValues = new() + { + { nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved }, + { nameof(Title), () => Book.TitleSortable() }, + { nameof(Series), () => Book.SeriesSortable() }, + { nameof(Length), () => GetLengthInMinutes() }, + { nameof(MyRating), () => Book.UserDefinedItem.Rating }, + { nameof(PurchaseDate), () => GetPurchaseDate() }, + { nameof(ProductRating), () => Book.Rating }, + { nameof(Authors), () => Authors }, + { nameof(Narrators), () => Narrators }, + { nameof(Description), () => Description }, + { nameof(Category), () => Category }, + { nameof(Misc), () => Misc }, + { nameof(LastDownload), () => LastDownload }, + { nameof(BookTags), () => BookTags ?? string.Empty }, + { nameof(Liberate), () => Liberate }, + { nameof(DateAdded), () => DateAdded }, + }; + } + + public object GetMemberValue(string memberName) => memberValues[memberName](); + public IComparer GetMemberComparer(Type memberType) + => memberTypeComparers.TryGetValue(memberType, out IComparer value) ? value : memberTypeComparers[memberType.BaseType]; + + private readonly Dictionary> memberValues; + + // Instantiate comparers for every exposed member object type. + private static readonly Dictionary memberTypeComparers = new() + { + { typeof(RemoveStatus), new ObjectComparer() }, + { typeof(string), new ObjectComparer() }, + { typeof(int), new ObjectComparer() }, + { typeof(float), new ObjectComparer() }, + { typeof(bool), new ObjectComparer() }, + { typeof(Rating), new ObjectComparer() }, + { typeof(DateTime), new ObjectComparer() }, + { typeof(EntryStatus), new ObjectComparer() }, + { typeof(LastDownloadStatus), new ObjectComparer() }, + }; + + #endregion + + #region Cover Art + + protected void LoadCover() + { + // Get cover art. If it's default, subscribe to PictureCached + (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); + + if (isDefault) + PictureStorage.PictureCached += PictureStorage_PictureCached; + + // Mutable property. Set the field so PropertyChanged isn't fired. + _cover = Liberate.LoadImage(picture); + } + + private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) + { + // state validation + if (e?.Definition.PictureId is null || + Book?.PictureId is null || + e.Picture?.Length == 0) + return; + + // logic validation + if (e.Definition.PictureId == Book.PictureId) + { + Cover = Liberate.LoadImage(e.Picture); + PictureStorage.PictureCached -= PictureStorage_PictureCached; + } + } + + #endregion + + #region Static library display functions + + /// This information should not change during lifetime, so call only once. + private static string GetDescriptionDisplay(Book book) + { + var doc = new HtmlAgilityPack.HtmlDocument(); + doc.LoadHtml(book?.Description?.Replace("

", "\r\n\r\n

") ?? ""); + return doc.DocumentNode.InnerText.Trim(); + } + + private static string TrimTextToWord(string text, int maxLength) + { + return + text.Length <= maxLength ? + text : + text.Substring(0, maxLength - 3) + "..."; + } + + /// + /// This information should not change during lifetime, so call only once. + /// Maximum of 5 text rows will fit in 80-pixel row height. + /// + private static string GetMiscDisplay(LibraryBook libraryBook) + { + var details = new List(); + + var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]"); + var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]"); + + details.Add($"Account: {locale} - {acct}"); + + if (libraryBook.Book.HasPdf()) + details.Add("Has PDF"); + if (libraryBook.Book.IsAbridged) + details.Add("Abridged"); + if (libraryBook.Book.DatePublished.HasValue) + details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}"); + // this goes last since it's most likely to have a line-break + if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher)) + details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}"); + + if (!details.Any()) + return "[details not imported]"; + + return string.Join("\r\n", details); + } + + #endregion + + ~GridEntry() + { + PictureStorage.PictureCached -= PictureStorage_PictureCached; + UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; + } + } +} diff --git a/Source/LibationUiBase/GridView/IGridEntry.cs b/Source/LibationUiBase/GridView/IGridEntry.cs new file mode 100644 index 00000000..a90c3436 --- /dev/null +++ b/Source/LibationUiBase/GridView/IGridEntry.cs @@ -0,0 +1,34 @@ +using DataLayer; +using Dinah.Core.DataBinding; +using System; +using System.ComponentModel; + +namespace LibationUiBase.GridView +{ + public interface IGridEntry : IMemberComparable, INotifyPropertyChanged + { + EntryStatus Liberate { get; } + float SeriesIndex { get; } + string AudibleProductId { get; } + string LongDescription { get; } + LibraryBook LibraryBook { get; } + Book Book { get; } + DateTime DateAdded { get; } + bool? Remove { get; set; } + string PurchaseDate { get; } + object Cover { get; } + string Length { get; } + LastDownloadStatus LastDownload { get; } + string Series { get; } + string Title { get; } + string Authors { get; } + string Narrators { get; } + string Category { get; } + string Misc { get; } + string Description { get; } + Rating ProductRating { get; } + Rating MyRating { get; set; } + string BookTags { get; } + void UpdateLibraryBook(LibraryBook libraryBook); + } +} diff --git a/Source/LibationUiBase/GridView/ILibraryBookEntry.cs b/Source/LibationUiBase/GridView/ILibraryBookEntry.cs new file mode 100644 index 00000000..b6ece000 --- /dev/null +++ b/Source/LibationUiBase/GridView/ILibraryBookEntry.cs @@ -0,0 +1,7 @@ +namespace LibationUiBase.GridView +{ + public interface ILibraryBookEntry : IGridEntry + { + ISeriesEntry Parent { get; } + } +} diff --git a/Source/LibationUiBase/GridView/ISeriesEntry.cs b/Source/LibationUiBase/GridView/ISeriesEntry.cs new file mode 100644 index 00000000..f3e7e55e --- /dev/null +++ b/Source/LibationUiBase/GridView/ISeriesEntry.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace LibationUiBase.GridView +{ + public interface ISeriesEntry : IGridEntry + { + List Children { get; } + void ChildRemoveUpdate(); + void RemoveChild(ILibraryBookEntry libraryBookEntry); + } +} diff --git a/Source/LibationUiBase/LastDownloadStatus.cs b/Source/LibationUiBase/GridView/LastDownloadStatus.cs similarity index 97% rename from Source/LibationUiBase/LastDownloadStatus.cs rename to Source/LibationUiBase/GridView/LastDownloadStatus.cs index eddba0e1..4eda37bf 100644 --- a/Source/LibationUiBase/LastDownloadStatus.cs +++ b/Source/LibationUiBase/GridView/LastDownloadStatus.cs @@ -1,7 +1,7 @@ using DataLayer; using System; -namespace LibationUiBase +namespace LibationUiBase.GridView { public class LastDownloadStatus : IComparable { diff --git a/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs b/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs new file mode 100644 index 00000000..93ef84d9 --- /dev/null +++ b/Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs @@ -0,0 +1,34 @@ +using DataLayer; +using System; +using System.ComponentModel; + +namespace LibationUiBase.GridView +{ + /// The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode + public class LibraryBookEntry : GridEntry, ILibraryBookEntry where TStatus : IEntryStatus + { + [Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded; + [Browsable(false)] public ISeriesEntry Parent { get; } + + public override bool? Remove + { + get => remove; + set + { + remove = value ?? false; + + Parent?.ChildRemoveUpdate(); + RaisePropertyChanged(nameof(Remove)); + } + } + + public LibraryBookEntry(LibraryBook libraryBook, ISeriesEntry parent = null) + { + Parent = parent; + UpdateLibraryBook(libraryBook); + LoadCover(); + } + + protected override string GetBookTags() => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated); + } +} diff --git a/Source/LibationUiBase/ObjectComparer[T].cs b/Source/LibationUiBase/GridView/ObjectComparer[T].cs similarity index 84% rename from Source/LibationUiBase/ObjectComparer[T].cs rename to Source/LibationUiBase/GridView/ObjectComparer[T].cs index cacd3044..e66beca0 100644 --- a/Source/LibationUiBase/ObjectComparer[T].cs +++ b/Source/LibationUiBase/GridView/ObjectComparer[T].cs @@ -1,7 +1,7 @@ using System; using System.Collections; -namespace LibationUiBase +namespace LibationUiBase.GridView { public class ObjectComparer : IComparer where T : IComparable { diff --git a/Source/LibationAvalonia/ViewModels/QueryExtensions.cs b/Source/LibationUiBase/GridView/QueryExtensions.cs similarity index 59% rename from Source/LibationAvalonia/ViewModels/QueryExtensions.cs rename to Source/LibationUiBase/GridView/QueryExtensions.cs index 91357a45..9f899212 100644 --- a/Source/LibationAvalonia/ViewModels/QueryExtensions.cs +++ b/Source/LibationUiBase/GridView/QueryExtensions.cs @@ -3,24 +3,24 @@ using System; using System.Collections.Generic; using System.Linq; -namespace LibationAvalonia.ViewModels +namespace LibationUiBase.GridView { #nullable enable - internal static class QueryExtensions + public static class QueryExtensions { - public static IEnumerable BookEntries(this IEnumerable gridEntries) - => gridEntries.OfType(); + public static IEnumerable BookEntries(this IEnumerable gridEntries) + => gridEntries.OfType(); - public static IEnumerable SeriesEntries(this IEnumerable gridEntries) - => gridEntries.OfType(); + public static IEnumerable SeriesEntries(this IEnumerable gridEntries) + => gridEntries.OfType(); - public static T? FindByAsin(this IEnumerable gridEntries, string audibleProductID) where T : GridEntry + public static T? FindByAsin(this IEnumerable gridEntries, string audibleProductID) where T : IGridEntry => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); - public static IEnumerable EmptySeries(this IEnumerable gridEntries) + public static IEnumerable EmptySeries(this IEnumerable gridEntries) => gridEntries.SeriesEntries().Where(i => i.Children.Count == 0); - public static SeriesEntry? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode) + public static ISeriesEntry? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode) { if (seriesEpisode.Book.SeriesLink is null) return null; diff --git a/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs new file mode 100644 index 00000000..37fd472e --- /dev/null +++ b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs @@ -0,0 +1,68 @@ +using DataLayer; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LibationUiBase.GridView +{ + /// The View Model for a LibraryBook that is ContentType.Parent + public class SeriesEntry : GridEntry, ISeriesEntry where TStatus : IEntryStatus + { + public List Children { get; } + public override DateTime DateAdded => Children.Max(c => c.DateAdded); + + private bool suspendCounting = false; + public void ChildRemoveUpdate() + { + if (suspendCounting) return; + + var removeCount = Children.Count(c => c.Remove == true); + + remove = removeCount == 0 ? false : removeCount == Children.Count ? true : null; + RaisePropertyChanged(nameof(Remove)); + } + + public override bool? Remove + { + get => remove; + set + { + remove = value ?? false; + + suspendCounting = true; + + foreach (var item in Children) + item.Remove = value; + + suspendCounting = false; + RaisePropertyChanged(nameof(Remove)); + } + } + + public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent, new[] { child }) { } + public SeriesEntry(LibraryBook parent, IEnumerable children) + { + LastDownload = new(); + SeriesIndex = -1; + + Children = children + .Select(c => new LibraryBookEntry(c, this)) + .OrderBy(c => c.SeriesIndex) + .ToList(); + + UpdateLibraryBook(parent); + LoadCover(); + } + + public void RemoveChild(ILibraryBookEntry lbe) + { + Children.Remove(lbe); + PurchaseDate = GetPurchaseDateString(); + Length = GetBookLengthString(); + } + + protected override string GetBookTags() => null; + protected override int GetLengthInMinutes() => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); + protected override DateTime GetPurchaseDate() => Children.Min(c => c.LibraryBook.DateAdded); + } +} diff --git a/Source/LibationUiBase/IcoEncoder.cs b/Source/LibationUiBase/IcoEncoder.cs index d6546f7f..92b445f9 100644 --- a/Source/LibationUiBase/IcoEncoder.cs +++ b/Source/LibationUiBase/IcoEncoder.cs @@ -1,10 +1,10 @@ -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.PixelFormats; using System; using System.IO; using System.Threading; using System.Threading.Tasks; -using SixLabors.ImageSharp.Formats; namespace LibationUiBase { diff --git a/Source/LibationUiBase/SampleRateSelection.cs b/Source/LibationUiBase/SampleRateSelection.cs index bc7c8ee0..d91c1ea8 100644 --- a/Source/LibationUiBase/SampleRateSelection.cs +++ b/Source/LibationUiBase/SampleRateSelection.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace LibationUiBase +namespace LibationUiBase { public class SampleRateSelection { diff --git a/Source/LibationUiBase/TrackedQueue[T].cs b/Source/LibationUiBase/TrackedQueue[T].cs index 11f1a27e..2df99d7e 100644 --- a/Source/LibationUiBase/TrackedQueue[T].cs +++ b/Source/LibationUiBase/TrackedQueue[T].cs @@ -121,11 +121,11 @@ namespace LibationUiBase public void ClearCurrent() { - lock(lockObject) + lock (lockObject) Current = null; RebuildSecondary(); } - + public bool RemoveCompleted(T item) { bool itemsRemoved; diff --git a/Source/LibationUiBase/Upgrader.cs b/Source/LibationUiBase/Upgrader.cs index 230b2410..1513966a 100644 --- a/Source/LibationUiBase/Upgrader.cs +++ b/Source/LibationUiBase/Upgrader.cs @@ -40,7 +40,7 @@ namespace LibationUiBase public event EventHandler DownloadProgress; public event EventHandler DownloadCompleted; - public async Task CheckForUpgradeAsync(Func upgradeAvailableHandler) + public async Task CheckForUpgradeAsync(Func upgradeAvailableHandler) { try { diff --git a/Source/LibationWinForms/GridView/EditTagsDataGridViewImageButtonColumn.cs b/Source/LibationWinForms/GridView/EditTagsDataGridViewImageButtonColumn.cs index 9cc165dd..57d5117a 100644 --- a/Source/LibationWinForms/GridView/EditTagsDataGridViewImageButtonColumn.cs +++ b/Source/LibationWinForms/GridView/EditTagsDataGridViewImageButtonColumn.cs @@ -1,10 +1,11 @@ using System.Drawing; using System.Windows.Forms; using Dinah.Core.WindowsDesktop.Forms; +using LibationUiBase.GridView; namespace LibationWinForms.GridView { - public class EditTagsDataGridViewImageButtonColumn : DataGridViewButtonColumn + public class EditTagsDataGridViewImageButtonColumn : DataGridViewButtonColumn { public EditTagsDataGridViewImageButtonColumn() { @@ -15,34 +16,18 @@ namespace LibationWinForms.GridView internal class EditTagsDataGridViewImageButtonCell : DataGridViewImageButtonCell { private static Image ButtonImage { get; } = Properties.Resources.edit_25x25; - private static Color HiddenForeColor { get; } = Color.LightGray; protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { - if (rowIndex >= 0 && DataGridView.GetBoundItem(rowIndex) is SeriesEntry) - { + if (rowIndex >= 0 && DataGridView.GetBoundItem(rowIndex) is ISeriesEntry) base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); - return; - } - - var tagsString = (string)value; - - var foreColor = tagsString?.Contains("hidden") == true ? HiddenForeColor : DataGridView.DefaultCellStyle.ForeColor; - - if (DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor != foreColor) - { - DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor = foreColor; - } - - if (tagsString?.Length == 0) + else if (value is string tagStr && tagStr.Length == 0) { base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); DrawButtonImage(graphics, ButtonImage, cellBounds); } else - { base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); - } } } } diff --git a/Source/LibationWinForms/GridView/GridEntry.cs b/Source/LibationWinForms/GridView/GridEntry.cs deleted file mode 100644 index a8594356..00000000 --- a/Source/LibationWinForms/GridView/GridEntry.cs +++ /dev/null @@ -1,233 +0,0 @@ -using ApplicationServices; -using DataLayer; -using Dinah.Core; -using Dinah.Core.DataBinding; -using Dinah.Core.WindowsDesktop.Drawing; -using FileLiberator; -using LibationFileManager; -using LibationUiBase; -using System; -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.Drawing; -using System.Linq; -using System.Threading.Tasks; - -namespace LibationWinForms.GridView -{ - public enum RemoveStatus - { - NotRemoved, - Removed, - SomeRemoved - } - /// The View Model base for the DataGridView - public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable - { - [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; - [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } - [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 Book Book => LibraryBook.Book; - - [Browsable(false)] public abstract bool IsSeries { get; } - [Browsable(false)] public abstract bool IsEpisode { get; } - [Browsable(false)] public abstract bool IsBook { get; } - - #region Model properties exposed to the view - - protected RemoveStatus _remove = RemoveStatus.NotRemoved; - public abstract RemoveStatus Remove { get; set; } - - public abstract LiberateButtonStatus Liberate { get; } - public Image Cover - { - get => _cover; - protected set - { - _cover = value; - NotifyPropertyChanged(); - } - } - 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 virtual LastDownloadStatus LastDownload { get; protected set; } = new(); - public string Description { get; protected set; } - public string ProductRating { get; protected set; } - protected Rating _myRating; - public Rating MyRating - { - get => _myRating; - set - { - if (_myRating != value - && value.OverallRating != 0 - && updateReviewTask?.IsCompleted is not false) - { - updateReviewTask = UpdateRating(value); - updateReviewTask.ContinueWith(t => - { - if (t.Result) - { - _myRating = value; - LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, value); - } - NotifyPropertyChanged(); - }); - } - } - } - public abstract string DisplayTags { get; } - - #endregion - - #region User rating - - private Task updateReviewTask; - private async Task UpdateRating(Rating rating) - { - var api = await LibraryBook.GetApiAsync(); - - return await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating); - } - #endregion - - #region Sorting - - public GridEntry() => _memberValues = CreateMemberValueDictionary(); - - // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable - // Used by GridEntryBindingList for all sorting - public virtual object GetMemberValue(string memberName) => _memberValues[memberName](); - public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType]; - protected abstract Dictionary> CreateMemberValueDictionary(); - private Dictionary> _memberValues { get; set; } - - // Instantiate comparers for every exposed member object type. - private static readonly Dictionary _memberTypeComparers = new() - { - { typeof(RemoveStatus), new ObjectComparer() }, - { typeof(string), new ObjectComparer() }, - { typeof(int), new ObjectComparer() }, - { typeof(float), new ObjectComparer() }, - { typeof(bool), new ObjectComparer() }, - { typeof(DateTime), new ObjectComparer() }, - { typeof(LiberateButtonStatus), new ObjectComparer() }, - { typeof(LastDownloadStatus), new ObjectComparer() }, - }; - - #endregion - - #region Cover Art - - private Image _cover; - protected void LoadCover() - { - // Get cover art. If it's default, subscribe to PictureCached - (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); - - if (isDefault) - PictureStorage.PictureCached += PictureStorage_PictureCached; - - // Mutable property. Set the field so PropertyChanged isn't fired. - _cover = loadImage(picture); - } - - private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) - { - // state validation - if (e is null || - e.Definition.PictureId is null || - Book?.PictureId is null || - e.Picture is null || - e.Picture.Length == 0) - return; - - // logic validation - if (e.Definition.PictureId == Book.PictureId) - { - Cover = loadImage(e.Picture); - PictureStorage.PictureCached -= PictureStorage_PictureCached; - } - } - - - private Image loadImage(byte[] picture) - { - try - { - return ImageReader.ToImage(picture); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book); - return Properties.Resources.default_cover_80x80; - } - } - - #endregion - - #region Static library display functions - - /// This information should not change during lifetime, so call only once. - protected static string GetDescriptionDisplay(Book book) - { - var doc = new HtmlAgilityPack.HtmlDocument(); - doc.LoadHtml(book?.Description?.Replace("

", "\r\n\r\n

") ?? ""); - return doc.DocumentNode.InnerText.Trim(); - } - - protected static string TrimTextToWord(string text, int maxLength) - { - return - text.Length <= maxLength ? - text : - text.Substring(0, maxLength - 3) + "..."; - } - - - /// - /// This information should not change during lifetime, so call only once. - /// Maximum of 5 text rows will fit in 80-pixel row height. - /// - protected static string GetMiscDisplay(LibraryBook libraryBook) - { - var details = new List(); - - var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]"); - var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]"); - - details.Add($"Account: {locale} - {acct}"); - - if (libraryBook.Book.HasPdf()) - details.Add("Has PDF"); - if (libraryBook.Book.IsAbridged) - details.Add("Abridged"); - if (libraryBook.Book.DatePublished.HasValue) - details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}"); - // this goes last since it's most likely to have a line-break - if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher)) - details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}"); - - if (!details.Any()) - return "[details not imported]"; - - return string.Join("\r\n", details); - } - - #endregion - - ~GridEntry() - { - PictureStorage.PictureCached -= PictureStorage_PictureCached; - } - } -} diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index e2b45c61..7350334b 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -1,6 +1,7 @@ using ApplicationServices; using Dinah.Core.DataBinding; using LibationSearchEngine; +using LibationUiBase.GridView; using System; using System.Collections.Generic; using System.ComponentModel; @@ -20,20 +21,20 @@ namespace LibationWinForms.GridView * Remove is overridden to ensure that removed items are removed from * the base list (visible items) as well as the FilterRemoved list. */ - internal class GridEntryBindingList : BindingList, IBindingListView + internal class GridEntryBindingList : BindingList, IBindingListView { - public GridEntryBindingList() : base(new List()) { } - public GridEntryBindingList(IEnumerable enumeration) : base(new List(enumeration)) { } + public GridEntryBindingList() : base(new List()) { } + public GridEntryBindingList(IEnumerable enumeration) : base(new List(enumeration)) { } /// All items in the list, including those filtered out. - public List AllItems() => Items.Concat(FilterRemoved).ToList(); + public List AllItems() => Items.Concat(FilterRemoved).ToList(); public bool SupportsFiltering => true; public string Filter { get => FilterString; set => ApplyFilter(value); } /// When true, itms will not be checked filtered by search criteria on item changed public bool SuspendFilteringOnUpdate { get; set; } - protected MemberComparer Comparer { get; } = new(); + protected MemberComparer Comparer { get; } = new(); protected override bool SupportsSortingCore => true; protected override bool SupportsSearchingCore => true; protected override bool IsSortedCore => isSorted; @@ -41,7 +42,7 @@ namespace LibationWinForms.GridView protected override ListSortDirection SortDirectionCore => listSortDirection; /// Items that were removed from the base list due to filtering - private readonly List FilterRemoved = new(); + private readonly List FilterRemoved = new(); private string FilterString; private SearchResultSet SearchResults; private bool isSorted; @@ -59,7 +60,7 @@ namespace LibationWinForms.GridView public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException(); #endregion - public new void Remove(GridEntry entry) + public new void Remove(IGridEntry entry) { FilterRemoved.Remove(entry); base.Remove(entry); @@ -73,7 +74,7 @@ namespace LibationWinForms.GridView FilterString = filterString; SearchResults = SearchEngineCommands.Search(filterString); - var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe); + var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe); //Find all series containing children that match the search criteria var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); @@ -99,7 +100,7 @@ namespace LibationWinForms.GridView ExpandItem(series); } - public void CollapseItem(SeriesEntry sEntry) + public void CollapseItem(ISeriesEntry sEntry) { foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList()) { @@ -110,7 +111,7 @@ namespace LibationWinForms.GridView sEntry.Liberate.Expanded = false; } - public void ExpandItem(SeriesEntry sEntry) + public void ExpandItem(ISeriesEntry sEntry) { var sindex = Items.IndexOf(sEntry); @@ -133,7 +134,7 @@ namespace LibationWinForms.GridView foreach (var item in FilterRemoved.ToList()) { - if (item is SeriesEntry || (item is LibraryBookEntry lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded))) + if (item is ISeriesEntry || (item is ILibraryBookEntry lbe && (lbe.Liberate.IsBook || lbe.Parent.Liberate.Expanded))) { FilterRemoved.Remove(item); InsertItem(visibleCount++, item); @@ -145,7 +146,7 @@ namespace LibationWinForms.GridView else //No user sort is applied, so do default sorting by DateAdded, descending { - Comparer.PropertyName = nameof(GridEntry.DateAdded); + Comparer.PropertyName = nameof(IGridEntry.DateAdded); Comparer.Direction = ListSortDirection.Descending; Sort(); } @@ -172,9 +173,9 @@ namespace LibationWinForms.GridView protected void Sort() { - var itemsList = (List)Items; + var itemsList = (List)Items; - var children = itemsList.BookEntries().Where(i => i.Parent is not null).ToList(); + var children = itemsList.BookEntries().Where(i => i.Liberate.IsEpisode).ToList(); var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList(); @@ -198,7 +199,7 @@ namespace LibationWinForms.GridView { if (e.ListChangedType == ListChangedType.ItemChanged) { - if (FilterString is not null && !SuspendFilteringOnUpdate && Items[e.NewIndex] is LibraryBookEntry lbItem) + if (FilterString is not null && !SuspendFilteringOnUpdate && Items[e.NewIndex] is ILibraryBookEntry lbItem) { SearchResults = SearchEngineCommands.Search(FilterString); if (!SearchResults.Docs.Any(d => d.ProductId == lbItem.AudibleProductId)) diff --git a/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs b/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs index c970f57d..0fca8f68 100644 --- a/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs +++ b/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs @@ -1,4 +1,4 @@ -using LibationUiBase; +using LibationUiBase.GridView; using System; using System.Drawing; using System.Windows.Forms; @@ -26,7 +26,8 @@ namespace LibationWinForms.GridView private LastDownloadStatus LastDownload => (LastDownloadStatus)Value; protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { - ToolTipText = ((LastDownloadStatus)value).ToolTipText; + if (value is LastDownloadStatus lastDl) + ToolTipText = lastDl.ToolTipText; base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); } diff --git a/Source/LibationWinForms/GridView/LiberateButtonStatus.cs b/Source/LibationWinForms/GridView/LiberateButtonStatus.cs deleted file mode 100644 index 80808b12..00000000 --- a/Source/LibationWinForms/GridView/LiberateButtonStatus.cs +++ /dev/null @@ -1,38 +0,0 @@ -using DataLayer; -using System; - -namespace LibationWinForms.GridView -{ - public class LiberateButtonStatus : IComparable - { - public LiberatedStatus BookStatus { get; set; } - public LiberatedStatus? PdfStatus { get; set; } - public bool Expanded { get; set; } - public bool IsSeries { get; } - private bool IsAbsent { get; } - public bool IsUnavailable => !IsSeries & IsAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated); - - public LiberateButtonStatus(bool isSeries, bool isAbsent) - { - IsSeries = isSeries; - IsAbsent = isAbsent; - } - - /// - /// Defines the Liberate column's sorting behavior - /// - public int CompareTo(object obj) - { - if (obj is not LiberateButtonStatus second) return -1; - - if (IsSeries && !second.IsSeries) return -1; - else if (!IsSeries && second.IsSeries) return 1; - else if (IsSeries && second.IsSeries) return 0; - else if (IsUnavailable && !second.IsUnavailable) return 1; - else if (!IsUnavailable && second.IsUnavailable) return -1; - else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1; - else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1; - else return BookStatus.CompareTo(second.BookStatus); - } - } -} diff --git a/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs b/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs index a588610a..89754463 100644 --- a/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs +++ b/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs @@ -1,8 +1,6 @@ -using System; +using DataLayer; using System.Drawing; using System.Windows.Forms; -using DataLayer; -using Dinah.Core.WindowsDesktop.Forms; namespace LibationWinForms.GridView { @@ -16,78 +14,26 @@ namespace LibationWinForms.GridView internal class LiberateDataGridViewImageButtonCell : DataGridViewImageButtonCell { - private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230); private static readonly Brush DISABLED_GRAY = new SolidBrush(Color.FromArgb(0x60, Color.LightGray)); + private static readonly Color HiddenForeColor = Color.LightGray; protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { - if (value is LiberateButtonStatus status) + if (value is WinFormsEntryStatus status) { - if (status.BookStatus is LiberatedStatus.Error || status.IsUnavailable) //Don't paint the button graphic paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground; - if (rowIndex >= 0 && DataGridView.GetBoundItem(rowIndex) is LibraryBookEntry lbEntry && lbEntry.Parent is not null) - DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = SERIES_BG_COLOR; - + DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = (Color)status.BackgroundBrush; + DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor = status.Opacity == 1 ? DataGridView.DefaultCellStyle.ForeColor : HiddenForeColor; base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); - if (status.IsSeries) - { - DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus : Properties.Resources.plus, cellBounds); + DrawButtonImage(graphics, (Image)status.ButtonImage, cellBounds); + ToolTipText = status.ToolTip; - ToolTipText = status.Expanded ? "Click to Collpase" : "Click to Expand"; - } - else - { - (string mouseoverText, Bitmap buttonImage) = GetLiberateDisplay(status.BookStatus, status.PdfStatus); - - DrawButtonImage(graphics, buttonImage, cellBounds); - - if (status.IsUnavailable) - { - //Create the "disabled" look by painting a transparent gray box over the buttom image. - graphics.FillRectangle(DISABLED_GRAY, cellBounds); - ToolTipText = "This book cannot be downloaded\r\nbecause it wasn't found during\r\nthe most recent library scan"; - } - else - ToolTipText = mouseoverText; - } + if (status.IsUnavailable || status.Opacity < 1) + graphics.FillRectangle(DISABLED_GRAY, cellBounds); } } - - private static (string mouseoverText, Bitmap buttonImage) GetLiberateDisplay(LiberatedStatus liberatedStatus, LiberatedStatus? pdfStatus) - { - if (liberatedStatus == LiberatedStatus.Error) - return ("Book downloaded ERROR", Properties.Resources.error); - - (string libState, string image_lib) = liberatedStatus switch - { - LiberatedStatus.Liberated => ("Liberated", "green"), - LiberatedStatus.PartialDownload => ("File has been at least\r\npartially downloaded", "yellow"), - LiberatedStatus.NotLiberated => ("Book NOT downloaded", "red"), - _ => throw new Exception("Unexpected liberation state") - }; - - (string pdfState, string image_pdf) = pdfStatus switch - { - LiberatedStatus.Liberated => ("\r\nPDF downloaded", "_pdf_yes"), - LiberatedStatus.NotLiberated => ("\r\nPDF NOT downloaded", "_pdf_no"), - LiberatedStatus.Error => ("\r\nPDF downloaded ERROR", "_pdf_no"), - null => ("", ""), - _ => throw new Exception("Unexpected PDF state") - }; - - var mouseoverText = libState + pdfState; - - if (liberatedStatus == LiberatedStatus.NotLiberated || - liberatedStatus == LiberatedStatus.PartialDownload || - pdfStatus == LiberatedStatus.NotLiberated) - mouseoverText += "\r\nClick to complete"; - - var buttonImage = (Bitmap)Properties.Resources.ResourceManager.GetObject($"liberate_{image_lib}{image_pdf}"); - - return (mouseoverText, buttonImage); - } } } diff --git a/Source/LibationWinForms/GridView/LibraryBookEntry.cs b/Source/LibationWinForms/GridView/LibraryBookEntry.cs deleted file mode 100644 index c0da5654..00000000 --- a/Source/LibationWinForms/GridView/LibraryBookEntry.cs +++ /dev/null @@ -1,177 +0,0 @@ -using ApplicationServices; -using DataLayer; -using Dinah.Core; -using LibationUiBase; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; - -namespace LibationWinForms.GridView -{ - /// The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode - public class LibraryBookEntry : GridEntry - { - [Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded; - [Browsable(false)] public SeriesEntry Parent { get; init; } - - [Browsable(false)] public override bool IsSeries => false; - [Browsable(false)] public override bool IsEpisode => Parent is not null; - [Browsable(false)] public override bool IsBook => Parent is null; - - #region Model properties exposed to the view - - private DateTime lastStatusUpdate = default; - private LiberatedStatus _bookStatus; - private LiberatedStatus? _pdfStatus; - - public override LastDownloadStatus LastDownload { get; protected set; } - - public override RemoveStatus Remove - { - get - { - return _remove; - } - set - { - _remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value; - Parent?.ChildRemoveUpdate(); - NotifyPropertyChanged(); - } - } - - public override LiberateButtonStatus Liberate - { - get - { - //Cache these statuses for faster sorting. - if ((DateTime.Now - lastStatusUpdate).TotalSeconds > 2) - { - _bookStatus = LibraryCommands.Liberated_Status(LibraryBook.Book); - _pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book); - lastStatusUpdate = DateTime.Now; - } - return new LiberateButtonStatus(isSeries: false, LibraryBook.AbsentFromLastScan) { BookStatus = _bookStatus, PdfStatus = _pdfStatus }; - } - } - public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated); - - #endregion - - public LibraryBookEntry(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); - - NotifyPropertyChanged(); - } - - private void setLibraryBook(LibraryBook libraryBook) - { - LibraryBook = libraryBook; - - Title = Book.Title; - Series = Book.SeriesNames(); - Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min"; - _myRating = Book.UserDefinedItem.Rating; - PurchaseDate = libraryBook.DateAdded.ToString("d"); - ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - Authors = Book.AuthorNames(); - Narrators = Book.NarratorNames(); - Category = string.Join(" > ", Book.CategoriesNames()); - Misc = GetMiscDisplay(libraryBook); - LastDownload = new(Book.UserDefinedItem); - LongDescription = GetDescriptionDisplay(Book); - Description = TrimTextToWord(LongDescription, 62); - SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; - - UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; - } - - #region detect changes to the model, update the view, and save to database. - - /// - /// This event handler receives notifications from the model that it has changed. - /// Notify the view that it's changed. - /// - private void UserDefinedItem_ItemChanged(object sender, string itemName) - { - var udi = sender as UserDefinedItem; - - if (udi.Book.AudibleProductId != Book.AudibleProductId) - return; - - // UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view. - // - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs - // - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view. - switch (itemName) - { - case nameof(udi.Tags): - Book.UserDefinedItem.Tags = udi.Tags; - NotifyPropertyChanged(nameof(DisplayTags)); - break; - case nameof(udi.BookStatus): - Book.UserDefinedItem.BookStatus = udi.BookStatus; - _bookStatus = udi.BookStatus; - NotifyPropertyChanged(nameof(Liberate)); - break; - case nameof(udi.PdfStatus): - Book.UserDefinedItem.SetPdfStatus(udi.PdfStatus); - _pdfStatus = udi.PdfStatus; - NotifyPropertyChanged(nameof(Liberate)); - break; - case nameof(udi.LastDownloaded): - LastDownload = new(udi); - NotifyPropertyChanged(nameof(LastDownload)); - break; - } - } - - /// Save edits to the database - public void Commit(string newTags, LiberatedStatus bookStatus, LiberatedStatus? pdfStatus) - // MVVM pass-through - => Book.UpdateUserDefinedItem(newTags, bookStatus: bookStatus, pdfStatus: pdfStatus); - - #endregion - - #region Data Sorting - - /// Create getters for all member object values by name - protected override Dictionary> CreateMemberValueDictionary() => new() - { - { nameof(Remove), () => Remove }, - { nameof(Title), () => Book.TitleSortable() }, - { nameof(Series), () => Book.SeriesSortable() }, - { nameof(Length), () => Book.LengthInMinutes }, - { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, - { nameof(PurchaseDate), () => LibraryBook.DateAdded }, - { nameof(ProductRating), () => Book.Rating.FirstScore() }, - { nameof(Authors), () => Authors }, - { nameof(Narrators), () => Narrators }, - { nameof(Description), () => Description }, - { nameof(Category), () => Category }, - { nameof(Misc), () => Misc }, - { nameof(LastDownload), () => LastDownload }, - { nameof(DisplayTags), () => DisplayTags }, - { nameof(Liberate), () => Liberate }, - { nameof(DateAdded), () => DateAdded }, - }; - - #endregion - - ~LibraryBookEntry() - { - UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; - } - } -} diff --git a/Source/LibationWinForms/GridView/MyRatingGridViewColumn.cs b/Source/LibationWinForms/GridView/MyRatingGridViewColumn.cs index f9aecde5..67b6f72c 100644 --- a/Source/LibationWinForms/GridView/MyRatingGridViewColumn.cs +++ b/Source/LibationWinForms/GridView/MyRatingGridViewColumn.cs @@ -31,7 +31,7 @@ namespace LibationWinForms.GridView public override Type EditType => typeof(MyRatingCellEditor); public override Type ValueType => typeof(Rating); - public MyRatingGridViewCell() { ToolTipText = "Click to change ratings"; } + public MyRatingGridViewCell() { ToolTipText = ReadOnly ? "" : "Click to change ratings"; } public override void InitializeEditingControl(int rowIndex, object initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle) { @@ -46,7 +46,7 @@ namespace LibationWinForms.GridView { if (value is Rating rating) { - ToolTipText = "Click to change ratings"; + ToolTipText = ReadOnly ? "" : "Click to change ratings"; var starString = rating.ToStarString(); base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, starString, starString, errorText, cellStyle, advancedBorderStyle, paintParts); diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 33f5a0a5..a0bbf00a 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -3,6 +3,7 @@ using AudibleUtilities; using DataLayer; using FileLiberator; using LibationFileManager; +using LibationUiBase.GridView; using LibationWinForms.Dialogs; using System; using System.Collections.Generic; @@ -32,7 +33,7 @@ namespace LibationWinForms.GridView #region Button controls private ImageDisplay imageDisplay; - private void productsGrid_CoverClicked(GridEntry liveGridEntry) + private void productsGrid_CoverClicked(IGridEntry liveGridEntry) { var picDef = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native); @@ -67,7 +68,7 @@ namespace LibationWinForms.GridView imageDisplay.Show(null); } - private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle) + private void productsGrid_DescriptionClicked(IGridEntry liveGridEntry, Rectangle cellRectangle) { var displayWindow = new DescriptionDisplay { @@ -86,11 +87,11 @@ namespace LibationWinForms.GridView displayWindow.Show(this); } - private void productsGrid_DetailsClicked(LibraryBookEntry liveGridEntry) + private void productsGrid_DetailsClicked(ILibraryBookEntry liveGridEntry) { var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook); if (bookDetailsForm.ShowDialog() == DialogResult.OK) - liveGridEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); + liveGridEntry.Book.UpdateUserDefinedItem(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); } #endregion @@ -102,7 +103,7 @@ namespace LibationWinForms.GridView public async Task RemoveCheckedBooksAsync() { - var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is RemoveStatus.Removed).ToList(); + var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is true).ToList(); if (selectedBooks.Count == 0) return; @@ -110,8 +111,8 @@ namespace LibationWinForms.GridView var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList(); var result = MessageBoxLib.ShowConfirmationDialog( booksToRemove, - // do not use `$` string interpolation. See impl. - "Are you sure you want to remove {0} from Libation's library?", + // 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) @@ -141,7 +142,7 @@ namespace LibationWinForms.GridView var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); foreach (var r in removable) - r.Remove = RemoveStatus.Removed; + r.Remove = true; productsGrid_RemovableCountChanged(this, null); } @@ -198,14 +199,14 @@ namespace LibationWinForms.GridView VisibleCountChanged?.Invoke(this, count); } - private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry) + private void productsGrid_LiberateClicked(ILibraryBookEntry liveGridEntry) { if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error && !liveGridEntry.Liberate.IsUnavailable) LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook); } - private void productsGrid_ConvertToMp3Clicked(LibraryBookEntry liveGridEntry) + private void productsGrid_ConvertToMp3Clicked(ILibraryBookEntry liveGridEntry) { if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error) ConvertToMp3Clicked?.Invoke(this, liveGridEntry.LibraryBook); @@ -213,7 +214,7 @@ namespace LibationWinForms.GridView private void productsGrid_RemovableCountChanged(object sender, EventArgs e) { - RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is RemoveStatus.Removed)); + RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is true)); } } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs index 55f0154d..51b5d09b 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs @@ -1,6 +1,8 @@ -namespace LibationWinForms.GridView +using LibationUiBase.GridView; + +namespace LibationWinForms.GridView { - partial class ProductsGrid + partial class ProductsGrid { /// /// Required designer variable. @@ -41,7 +43,7 @@ this.seriesGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.descriptionGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.categoryGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - this.productRatingGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.productRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn(); this.purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.myRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn(); this.miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); @@ -193,6 +195,7 @@ this.productRatingGVColumn.HeaderText = "Product Rating"; this.productRatingGVColumn.Name = "productRatingGVColumn"; this.productRatingGVColumn.ReadOnly = true; + this.productRatingGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; this.productRatingGVColumn.Width = 108; // // purchaseDateGVColumn @@ -229,7 +232,7 @@ // // tagAndDetailsGVColumn // - this.tagAndDetailsGVColumn.DataPropertyName = "DisplayTags"; + this.tagAndDetailsGVColumn.DataPropertyName = "BookTags"; this.tagAndDetailsGVColumn.HeaderText = "Tags and Details"; this.tagAndDetailsGVColumn.Name = "tagAndDetailsGVColumn"; this.tagAndDetailsGVColumn.ReadOnly = true; @@ -243,7 +246,7 @@ // // syncBindingSource // - this.syncBindingSource.DataSource = typeof(LibationWinForms.GridView.GridEntry); + this.syncBindingSource.DataSource = typeof(IGridEntry); // // ProductsGrid // @@ -275,7 +278,7 @@ private System.Windows.Forms.DataGridViewTextBoxColumn seriesGVColumn; private System.Windows.Forms.DataGridViewTextBoxColumn descriptionGVColumn; private System.Windows.Forms.DataGridViewTextBoxColumn categoryGVColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn productRatingGVColumn; + private MyRatingGridViewColumn productRatingGVColumn; private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn; private MyRatingGridViewColumn myRatingGVColumn; private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn; diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 9702c67b..84afdce0 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -1,21 +1,23 @@ -using System; +using ApplicationServices; +using DataLayer; +using Dinah.Core.WindowsDesktop.Forms; +using LibationFileManager; +using LibationUiBase.GridView; +using LibationWinForms.Dialogs; +using System; using System.Collections.Generic; using System.Data; +using System.Diagnostics; using System.Drawing; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -using ApplicationServices; -using DataLayer; -using Dinah.Core.WindowsDesktop.Forms; -using LibationFileManager; -using LibationWinForms.Dialogs; namespace LibationWinForms.GridView { - public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry); - public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry); - public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle); + public delegate void GridEntryClickedEventHandler(IGridEntry liveGridEntry); + public delegate void LibraryBookEntryClickedEventHandler(ILibraryBookEntry liveGridEntry); + public delegate void GridEntryRectangleClickedEventHandler(IGridEntry liveGridEntry, Rectangle cellRectangle); public partial class ProductsGrid : UserControl { @@ -34,7 +36,7 @@ namespace LibationWinForms.GridView => bindingList .BookEntries() .Select(lbe => lbe.LibraryBook); - internal IEnumerable GetAllBookEntries() + internal IEnumerable GetAllBookEntries() => bindingList.AllItems().BookEntries(); public ProductsGrid() @@ -62,7 +64,7 @@ namespace LibationWinForms.GridView return; var entry = getGridEntry(e.RowIndex); - if (entry is LibraryBookEntry lbEntry) + if (entry is ILibraryBookEntry lbEntry) { if (e.ColumnIndex == liberateGVColumn.Index) LiberateClicked?.Invoke(lbEntry); @@ -73,7 +75,7 @@ namespace LibationWinForms.GridView else if (e.ColumnIndex == coverGVColumn.Index) CoverClicked?.Invoke(lbEntry); } - else if (entry is SeriesEntry sEntry) + else if (entry is ISeriesEntry sEntry) { if (e.ColumnIndex == liberateGVColumn.Index) { @@ -82,8 +84,6 @@ namespace LibationWinForms.GridView else bindingList.ExpandItem(sEntry); - sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate)); - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); } else if (e.ColumnIndex == descriptionGVColumn.Index) @@ -98,108 +98,108 @@ namespace LibationWinForms.GridView RemovableCountChanged?.Invoke(this, EventArgs.Empty); } } - catch(Exception ex) + catch (Exception ex) { Serilog.Log.Logger.Error(ex, $"An error was encountered while processing a user click in the {nameof(ProductsGrid)}"); } } - private void gridEntryDataGridView_CellContextMenuStripNeeded(object sender, DataGridViewCellContextMenuStripNeededEventArgs e) - { + private void gridEntryDataGridView_CellContextMenuStripNeeded(object sender, DataGridViewCellContextMenuStripNeededEventArgs e) + { // header - if (e.RowIndex < 0) - return; + if (e.RowIndex < 0) + return; - // cover - if (e.ColumnIndex == coverGVColumn.Index) - return; + // cover + if (e.ColumnIndex == coverGVColumn.Index) + return; - // any non-stop light - if (e.ColumnIndex != liberateGVColumn.Index) + // any non-stop light + if (e.ColumnIndex != liberateGVColumn.Index) { - var copyContextMenu = new ContextMenuStrip(); - copyContextMenu.Items.Add("Copy", null, (_, __) => + var copyContextMenu = new ContextMenuStrip(); + copyContextMenu.Items.Add("Copy", null, (_, __) => { try - { - var dgv = (DataGridView)sender; - var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString(); + { + var dgv = (DataGridView)sender; + var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString(); Clipboard.SetDataObject(text, false, 5, 150); - } + } catch { } - }); + }); - e.ContextMenuStrip = copyContextMenu; - return; - } + e.ContextMenuStrip = copyContextMenu; + return; + } // else: stop light - var entry = getGridEntry(e.RowIndex); - if (entry.IsSeries) + var entry = getGridEntry(e.RowIndex); + if (entry.Liberate.IsSeries) return; var setDownloadMenuItem = new ToolStripMenuItem() - { - Text = "Set Download status to '&Downloaded'", - Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated + { + Text = "Set Download status to '&Downloaded'", + Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated }; - setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated); + setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated); - var setNotDownloadMenuItem = new ToolStripMenuItem() - { - Text = "Set Download status to '&Not Downloaded'", - Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated - }; - setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); + var setNotDownloadMenuItem = new ToolStripMenuItem() + { + Text = "Set Download status to '&Not Downloaded'", + Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated + }; + setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); - var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" }; + var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" }; removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook); - var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." }; - locateFileMenuItem.Click += (_, __) => - { - try - { - var openFileDialog = new OpenFileDialog - { - Title = $"Locate the audio file for '{entry.Book.Title}'", - Filter = "All files (*.*)|*.*", - FilterIndex = 1 - }; - if (openFileDialog.ShowDialog() == DialogResult.OK) - FilePathCache.Insert(entry.AudibleProductId, openFileDialog.FileName); - } - catch (Exception ex) - { - var msg = "Error saving book's location"; - MessageBoxLib.ShowAdminAlert(this, msg, msg, ex); - } - }; + var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." }; + locateFileMenuItem.Click += (_, __) => + { + try + { + var openFileDialog = new OpenFileDialog + { + Title = $"Locate the audio file for '{entry.Book.Title}'", + Filter = "All files (*.*)|*.*", + FilterIndex = 1 + }; + if (openFileDialog.ShowDialog() == DialogResult.OK) + FilePathCache.Insert(entry.AudibleProductId, openFileDialog.FileName); + } + catch (Exception ex) + { + var msg = "Error saving book's location"; + MessageBoxLib.ShowAdminAlert(this, msg, msg, ex); + } + }; var convertToMp3MenuItem = new ToolStripMenuItem { Text = "&Convert to Mp3", Enabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated }; - convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(entry as LibraryBookEntry); + convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(entry as ILibraryBookEntry); var bookRecordMenuItem = new ToolStripMenuItem { Text = "View &Bookmarks/Clips" }; bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this); var stopLightContextMenu = new ContextMenuStrip(); - stopLightContextMenu.Items.Add(setDownloadMenuItem); - stopLightContextMenu.Items.Add(setNotDownloadMenuItem); - stopLightContextMenu.Items.Add(removeMenuItem); - stopLightContextMenu.Items.Add(locateFileMenuItem); - stopLightContextMenu.Items.Add(convertToMp3MenuItem); + stopLightContextMenu.Items.Add(setDownloadMenuItem); + stopLightContextMenu.Items.Add(setNotDownloadMenuItem); + stopLightContextMenu.Items.Add(removeMenuItem); + stopLightContextMenu.Items.Add(locateFileMenuItem); + stopLightContextMenu.Items.Add(convertToMp3MenuItem); stopLightContextMenu.Items.Add(new ToolStripSeparator()); stopLightContextMenu.Items.Add(bookRecordMenuItem); e.ContextMenuStrip = stopLightContextMenu; - } + } - private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem(rowIndex); + private IGridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem(rowIndex); #endregion @@ -213,7 +213,7 @@ namespace LibationWinForms.GridView if (value) { foreach (var book in bindingList.AllItems()) - book.Remove = RemoveStatus.NotRemoved; + book.Remove = false; } removeGVColumn.DisplayIndex = 0; @@ -226,9 +226,8 @@ namespace LibationWinForms.GridView { var geList = dbBooks .Where(lb => lb.Book.IsProduct()) - .Select(b => new LibraryBookEntry(b)) - .Cast() - .ToList(); + .Select(b => new LibraryBookEntry(b)) + .ToList(); var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()); @@ -240,7 +239,7 @@ namespace LibationWinForms.GridView if (!seriesEpisodes.Any()) continue; - var seriesEntry = new SeriesEntry(parent, seriesEpisodes); + var seriesEntry = new SeriesEntry(parent, seriesEpisodes); geList.Add(seriesEntry); geList.AddRange(seriesEntry.Children); @@ -268,15 +267,25 @@ namespace LibationWinForms.GridView var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); var parentedEpisodes = dbBooks.ParentedEpisodes().ToList(); + var sw = new Stopwatch(); + 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)) + continue; + } + sw.Start(); + if (parentedEpisodes.Any(lb => lb == libraryBook)) + { + sw.Stop(); //Only try to add or update is this LibraryBook is a know child of a parent AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); + } + sw.Stop(); } bindingList.SuspendFilteringOnUpdate = false; @@ -297,14 +306,11 @@ namespace LibationWinForms.GridView RemoveBooks(removedBooks); } - public void RemoveBooks(IEnumerable removedBooks) + public 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); - removed.Parent.NotifyPropertyChanged(); - } + foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode)) + removed.Parent.RemoveChild(removed); //Remove series that have no children var removedSeries = @@ -312,28 +318,28 @@ namespace LibationWinForms.GridView .AllItems() .EmptySeries(); - foreach (var removed in removedBooks.Cast().Concat(removedSeries)) + 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, LibraryBookEntry existingBookEntry) + private void AddOrUpdateBook(LibraryBook book, ILibraryBookEntry existingBookEntry) { if (existingBookEntry is null) // Add the new product to top - bindingList.Insert(0, new LibraryBookEntry(book)); + bindingList.Insert(0, new LibraryBookEntry(book)); else // update existing existingBookEntry.UpdateLibraryBook(book); } - private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) + private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) { if (existingEpisodeEntry is null) { - LibraryBookEntry episodeEntry; + ILibraryBookEntry episodeEntry; var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); @@ -351,8 +357,7 @@ namespace LibationWinForms.GridView return; } - - seriesEntry = new SeriesEntry(seriesBook, episodeBook); + seriesEntry = new SeriesEntry(seriesBook, episodeBook); seriesEntries.Add(seriesEntry); episodeEntry = seriesEntry.Children[0]; @@ -362,10 +367,10 @@ namespace LibationWinForms.GridView else { //Series exists. Create and add episode child then update the SeriesEntry - episodeEntry = new(episodeBook) { Parent = seriesEntry }; + episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry); seriesEntry.Children.Add(episodeEntry); var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); - seriesEntry.UpdateSeries(seriesBook); + seriesEntry.UpdateLibraryBook(seriesBook); } //Add episode to the grid beneath the parent @@ -376,9 +381,6 @@ namespace LibationWinForms.GridView bindingList.ExpandItem(seriesEntry); else bindingList.CollapseItem(seriesEntry); - - seriesEntry.NotifyPropertyChanged(); - } else existingEpisodeEntry.UpdateLibraryBook(episodeBook); @@ -399,7 +401,7 @@ namespace LibationWinForms.GridView if (visibleCount != bindingList.Count) VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - } + } #endregion @@ -465,10 +467,10 @@ namespace LibationWinForms.GridView //Remove column is always first; removeGVColumn.DisplayIndex = 0; removeGVColumn.Visible = false; - removeGVColumn.ValueType = typeof(RemoveStatus); - removeGVColumn.FalseValue = RemoveStatus.NotRemoved; - removeGVColumn.TrueValue = RemoveStatus.Removed; - removeGVColumn.IndeterminateValue = RemoveStatus.SomeRemoved; + removeGVColumn.ValueType = typeof(bool?); + removeGVColumn.FalseValue = false; + removeGVColumn.TrueValue = true; + removeGVColumn.IndeterminateValue = null; } private void HideMenuItem_Click(object sender, EventArgs e) diff --git a/Source/LibationWinForms/GridView/QueryExtensions.cs b/Source/LibationWinForms/GridView/QueryExtensions.cs deleted file mode 100644 index 1fb52bdf..00000000 --- a/Source/LibationWinForms/GridView/QueryExtensions.cs +++ /dev/null @@ -1,44 +0,0 @@ -using DataLayer; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace LibationWinForms.GridView -{ -#nullable enable - internal static class QueryExtensions - { - public static IEnumerable BookEntries(this IEnumerable gridEntries) - => gridEntries.OfType(); - - public static IEnumerable SeriesEntries(this IEnumerable gridEntries) - => gridEntries.OfType(); - - public static T? FindByAsin(this IEnumerable gridEntries, string audibleProductID) where T : GridEntry - => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); - - public static IEnumerable EmptySeries(this IEnumerable gridEntries) - => gridEntries.SeriesEntries().Where(i => i.Children.Count == 0); - - public static SeriesEntry? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode) - { - if (seriesEpisode.Book.SeriesLink is null) return null; - - try - { - //Parent books will always have exactly 1 SeriesBook due to how - //they are imported in ApiExtended.getChildEpisodesAsync() - return gridEntries.SeriesEntries().FirstOrDefault( - lb => - seriesEpisode.Book.SeriesLink.Any( - s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId)); - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent)); - return null; - } - } - } -#nullable disable -} diff --git a/Source/LibationWinForms/GridView/SeriesEntry.cs b/Source/LibationWinForms/GridView/SeriesEntry.cs deleted file mode 100644 index 5cb9b55e..00000000 --- a/Source/LibationWinForms/GridView/SeriesEntry.cs +++ /dev/null @@ -1,133 +0,0 @@ -using DataLayer; -using Dinah.Core; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; - -namespace LibationWinForms.GridView -{ - /// The View Model for a LibraryBook that is ContentType.Parent - public class SeriesEntry : GridEntry - { - [Browsable(false)] public List Children { get; } - [Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded); - - [Browsable(false)] public override bool IsSeries => true; - [Browsable(false)] public override bool IsEpisode => false; - [Browsable(false)] public override bool IsBook => false; - - private bool suspendCounting = false; - public void ChildRemoveUpdate() - { - if (suspendCounting) return; - - var removeCount = Children.Count(c => c.Remove is RemoveStatus.Removed); - - if (removeCount == 0) - _remove = RemoveStatus.NotRemoved; - else if (removeCount == Children.Count) - _remove = RemoveStatus.Removed; - else - _remove = RemoveStatus.SomeRemoved; - NotifyPropertyChanged(nameof(Remove)); - } - - #region Model properties exposed to the view - public override RemoveStatus Remove - { - get - { - return _remove; - } - set - { - _remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value; - - suspendCounting = true; - - foreach (var item in Children) - item.Remove = value; - - suspendCounting = false; - - NotifyPropertyChanged(); - } - } - - public override LiberateButtonStatus Liberate { get; } - public override string DisplayTags { get; } = string.Empty; - - #endregion - - private SeriesEntry(LibraryBook parent) - { - Liberate = new LiberateButtonStatus(isSeries: true, isAbsent: false); - SeriesIndex = -1; - LibraryBook = parent; - LoadCover(); - } - - public SeriesEntry(LibraryBook parent, IEnumerable children) : this(parent) - { - Children = children - .Select(c => new LibraryBookEntry(c) { Parent = this }) - .OrderBy(c => c.SeriesIndex) - .ToList(); - UpdateSeries(parent); - } - - public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent) - { - Children = new() { new LibraryBookEntry(child) { Parent = this } }; - UpdateSeries(parent); - } - - public void UpdateSeries(LibraryBook parent) - { - LibraryBook = parent; - - Title = Book.Title; - Series = Book.SeriesNames(); - _myRating = Book.UserDefinedItem.Rating; - PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); - ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - Authors = Book.AuthorNames(); - Narrators = Book.NarratorNames(); - Category = string.Join(" > ", Book.CategoriesNames()); - Misc = GetMiscDisplay(LibraryBook); - LongDescription = GetDescriptionDisplay(Book); - Description = TrimTextToWord(LongDescription, 62); - - int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); - Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; - - NotifyPropertyChanged(); - } - - #region Data Sorting - - /// Create getters for all member object values by name - protected override Dictionary> CreateMemberValueDictionary() => new() - { - { nameof(Remove), () => Remove }, - { nameof(Title), () => Book.TitleSortable() }, - { nameof(Series), () => Book.SeriesSortable() }, - { nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) }, - { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, - { nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) }, - { nameof(ProductRating), () => Book.Rating.FirstScore() }, - { nameof(Authors), () => Authors }, - { nameof(Narrators), () => Narrators }, - { nameof(Description), () => Description }, - { nameof(Category), () => Category }, - { nameof(Misc), () => Misc }, - { nameof(LastDownload), () => LastDownload }, - { nameof(DisplayTags), () => string.Empty }, - { nameof(Liberate), () => Liberate }, - { nameof(DateAdded), () => DateAdded }, - }; - - #endregion - } -} diff --git a/Source/LibationWinForms/GridView/WinFormsEntryStatus.cs b/Source/LibationWinForms/GridView/WinFormsEntryStatus.cs new file mode 100644 index 00000000..e9c679d9 --- /dev/null +++ b/Source/LibationWinForms/GridView/WinFormsEntryStatus.cs @@ -0,0 +1,37 @@ +using DataLayer; +using Dinah.Core.WindowsDesktop.Drawing; +using LibationUiBase.GridView; +using System; +using System.Drawing; + +namespace LibationWinForms.GridView +{ + public class WinFormsEntryStatus : EntryStatus, IEntryStatus + { + private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230); + public override object BackgroundBrush => IsEpisode ? SERIES_BG_COLOR : SystemColors.ControlLightLight; + + private WinFormsEntryStatus(LibraryBook libraryBook) : base(libraryBook) { } + public static EntryStatus Create(LibraryBook libraryBook) => new WinFormsEntryStatus(libraryBook); + + protected override object LoadImage(byte[] picture) + { + try + { + return ImageReader.ToImage(picture); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book); + return Properties.Resources.default_cover_80x80; + } + } + + protected override Image GetResourceImage(string rescName) + { + var image = Properties.Resources.ResourceManager.GetObject(rescName); + + return image as Bitmap; + } + } +}