From 920f4df213c0b3a11deda3fff9d93277f652bf54 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 7 Jun 2022 15:28:16 -0600 Subject: [PATCH] Use new ContentType.Parent to add series info to grid display --- Source/LibationWinForms/GridView/GridEntry.cs | 120 +++++++---- .../GridView/GridEntryBindingList.cs | 14 +- .../GridView/LibraryBookEntry.cs | 89 +-------- .../GridView/ProductsDisplay.Designer.cs | 8 +- .../GridView/ProductsDisplay.cs | 5 +- .../LibationWinForms/GridView/ProductsGrid.cs | 188 +++++++++++------- .../GridView/QueryExtensions.cs | 58 ++++++ .../LibationWinForms/GridView/SeriesEntry.cs | 106 ++++------ 8 files changed, 313 insertions(+), 275 deletions(-) create mode 100644 Source/LibationWinForms/GridView/QueryExtensions.cs diff --git a/Source/LibationWinForms/GridView/GridEntry.cs b/Source/LibationWinForms/GridView/GridEntry.cs index 4b1a614c..b2e2ee21 100644 --- a/Source/LibationWinForms/GridView/GridEntry.cs +++ b/Source/LibationWinForms/GridView/GridEntry.cs @@ -1,4 +1,5 @@ using DataLayer; +using Dinah.Core; using Dinah.Core.DataBinding; using Dinah.Core.Drawing; using LibationFileManager; @@ -10,11 +11,14 @@ using System.Linq; namespace LibationWinForms.GridView { + /// The View Model base for the DataGridView public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable { - protected abstract Book Book { get; } - + public string AudibleProductId => Book.AudibleProductId; + public LibraryBook LibraryBook { get; protected set; } + protected Book Book => LibraryBook.Book; private Image _cover; + #region Model properties exposed to the view public Image Cover { @@ -25,20 +29,20 @@ namespace LibationWinForms.GridView NotifyPropertyChanged(); } } - public new bool InvokeRequired => base.InvokeRequired; + public float SeriesIndex { get; protected set; } + public string ProductRating { get; protected set; } + public string PurchaseDate { get; protected set; } + public string MyRating { get; protected set; } + public string Series { get; protected set; } + public string Title { get; protected set; } + public string Length { get; protected set; } + public string Authors { get; protected set; } + public string Narrators { get; protected set; } + public string Category { get; protected set; } + public string Misc { get; protected set; } + public string Description { get; protected set; } + public string LongDescription { get; protected set; } public abstract DateTime DateAdded { get; } - public abstract float SeriesIndex { get; } - public abstract string ProductRating { get; protected set; } - public abstract string PurchaseDate { get; protected set; } - public abstract string MyRating { get; protected set; } - public abstract string Series { get; protected set; } - public abstract string Title { get; protected set; } - public abstract string Length { get; protected set; } - public abstract string Authors { get; protected set; } - public abstract string Narrators { get; protected set; } - public abstract string Category { get; protected set; } - public abstract string Misc { get; protected set; } - public abstract string Description { get; protected set; } public abstract string DisplayTags { get; } public abstract LiberateButtonStatus Liberate { get; } #endregion @@ -54,6 +58,17 @@ namespace LibationWinForms.GridView public virtual object GetMemberValue(string memberName) => _memberValues[memberName](); public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType]; + // Instantiate comparers for every exposed member object type. + private static readonly Dictionary _memberTypeComparers = new() + { + { typeof(string), new ObjectComparer() }, + { typeof(int), new ObjectComparer() }, + { typeof(float), new ObjectComparer() }, + { typeof(bool), new ObjectComparer() }, + { typeof(DateTime), new ObjectComparer() }, + { typeof(LiberateButtonStatus), new ObjectComparer() }, + }; + #endregion protected void LoadCover() @@ -79,36 +94,61 @@ namespace LibationWinForms.GridView } } - // Instantiate comparers for every exposed member object type. - private static readonly Dictionary _memberTypeComparers = new() + #region Static library display functions + + /// + /// This information should not change during lifetime, so call only once. + /// + protected static string GetDescriptionDisplay(Book book) { - { typeof(string), new ObjectComparer() }, - { typeof(int), new ObjectComparer() }, - { typeof(float), new ObjectComparer() }, - { typeof(bool), new ObjectComparer() }, - { typeof(DateTime), new ObjectComparer() }, - { typeof(LiberateButtonStatus), new ObjectComparer() }, - }; + 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; } } - - internal static class GridEntryExtensions - { -#nullable enable - public static IEnumerable Series(this IEnumerable gridEntries) - => gridEntries.OfType(); - public static IEnumerable LibraryBooks(this IEnumerable gridEntries) - => gridEntries.OfType(); - public static LibraryBookEntry? FindBookByAsin(this IEnumerable gridEntries, string audibleProductID) - => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); - public static SeriesEntry? FindBookSeriesEntry(this IEnumerable gridEntries, IEnumerable matchSeries) - => gridEntries.Series().FirstOrDefault(i => matchSeries.Any(s => s.Series.Name == i.Series)); - public static IEnumerable EmptySeries(this IEnumerable gridEntries) - => gridEntries.Series().Where(i => i.Children.Count == 0); - public static bool IsEpisodeChild(this LibraryBook lb) => lb.Book.ContentType == ContentType.Episode; - } } diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 58be850c..e2b45c61 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -73,10 +73,10 @@ namespace LibationWinForms.GridView FilterString = filterString; SearchResults = SearchEngineCommands.Search(filterString); - var booksFilteredIn = Items.LibraryBooks().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) => (GridEntry)lbe); //Find all series containing children that match the search criteria - var seriesFilteredIn = Items.Series().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); + var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList(); @@ -89,19 +89,19 @@ namespace LibationWinForms.GridView public void CollapseAll() { - foreach (var series in Items.Series().ToList()) + foreach (var series in Items.SeriesEntries().ToList()) CollapseItem(series); } public void ExpandAll() { - foreach (var series in Items.Series().ToList()) + foreach (var series in Items.SeriesEntries().ToList()) ExpandItem(series); } public void CollapseItem(SeriesEntry sEntry) { - foreach (var episode in Items.LibraryBooks().Where(b => b.Parent == sEntry).ToList()) + foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList()) { FilterRemoved.Add(episode); base.Remove(episode); @@ -114,7 +114,7 @@ namespace LibationWinForms.GridView { var sindex = Items.IndexOf(sEntry); - foreach (var episode in FilterRemoved.LibraryBooks().Where(b => b.Parent == sEntry).ToList()) + foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).ToList()) { if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) { @@ -174,7 +174,7 @@ namespace LibationWinForms.GridView { var itemsList = (List)Items; - var children = itemsList.LibraryBooks().Where(i => i.Parent is not null).ToList(); + var children = itemsList.BookEntries().Where(i => i.Parent is not null).ToList(); var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList(); diff --git a/Source/LibationWinForms/GridView/LibraryBookEntry.cs b/Source/LibationWinForms/GridView/LibraryBookEntry.cs index be6836a8..cddde1f5 100644 --- a/Source/LibationWinForms/GridView/LibraryBookEntry.cs +++ b/Source/LibationWinForms/GridView/LibraryBookEntry.cs @@ -3,29 +3,13 @@ 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 - /// + /// The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode public class LibraryBookEntry : GridEntry { - #region implementation properties NOT exposed to the view - // hide from public fields from Data Source GUI with [Browsable(false)] - - [Browsable(false)] - public string AudibleProductId => Book.AudibleProductId; - [Browsable(false)] - public LibraryBook LibraryBook { get; private set; } - [Browsable(false)] - public string LongDescription { get; private set; } - #endregion - - protected override Book Book => LibraryBook.Book; - #region Model properties exposed to the view private DateTime lastStatusUpdate = default; @@ -33,21 +17,8 @@ namespace LibationWinForms.GridView private LiberatedStatus? _pdfStatus; public override DateTime DateAdded => LibraryBook.DateAdded; - public override float SeriesIndex => Book.SeriesLink.FirstOrDefault()?.Index ?? 0; - public override string ProductRating { get; protected set; } - public override string PurchaseDate { get; protected set; } - public override string MyRating { get; protected set; } - public override string Series { get; protected set; } - public override string Title { get; protected set; } - public override string Length { get; protected set; } - public override string Authors { get; protected set; } - public override string Narrators { get; protected set; } - public override string Category { get; protected set; } - public override string Misc { get; protected set; } - public override string Description { get; protected set; } public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated); - // these 2 values being in 1 field is the trick behind getting the liberated+pdf 'stoplight' icon to draw. See: LiberateDataGridViewImageButtonCell.Paint public override LiberateButtonStatus Liberate { get @@ -62,15 +33,17 @@ namespace LibationWinForms.GridView return new LiberateButtonStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false }; } } + #endregion + public SeriesEntry Parent { get; init; } + public LibraryBookEntry(LibraryBook libraryBook) { setLibraryBook(libraryBook); LoadCover(); } - public SeriesEntry Parent { get; init; } public void UpdateLibraryBook(LibraryBook libraryBook) { if (AudibleProductId != libraryBook.Book.AudibleProductId) @@ -100,12 +73,12 @@ namespace LibationWinForms.GridView Misc = GetMiscDisplay(libraryBook); 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. /// @@ -169,58 +142,6 @@ namespace LibationWinForms.GridView { nameof(DateAdded), () => DateAdded }, }; - - #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 ~LibraryBookEntry() diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs index c4b1a543..db9ca6a5 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs @@ -39,10 +39,10 @@ this.productsGrid.Name = "productsGrid"; this.productsGrid.Size = new System.Drawing.Size(1510, 380); this.productsGrid.TabIndex = 0; - this.productsGrid.LiberateClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked); - this.productsGrid.CoverClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_CoverClicked); - this.productsGrid.DetailsClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked); - this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked); + this.productsGrid.LiberateClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked); + this.productsGrid.CoverClicked += new LibationWinForms.GridView.GridEntryClickedEventHandler(this.productsGrid_CoverClicked); + this.productsGrid.DetailsClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked); + this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.GridEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked); this.productsGrid.VisibleCountChanged += new System.EventHandler(this.productsGrid_VisibleCountChanged); // // ProductsDisplay diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 7c411f3f..5f91a581 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -29,7 +29,7 @@ namespace LibationWinForms.GridView #region Button controls private ImageDisplay imageDisplay; - private async void productsGrid_CoverClicked(LibraryBookEntry liveGridEntry) + private async void productsGrid_CoverClicked(GridEntry liveGridEntry) { var picDefinition = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native); var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); @@ -52,7 +52,7 @@ namespace LibationWinForms.GridView imageDisplay.CoverPicture = await picDlTask; } - private void productsGrid_DescriptionClicked(LibraryBookEntry liveGridEntry, Rectangle cellRectangle) + private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle) { var displayWindow = new DescriptionDisplay { @@ -103,7 +103,6 @@ namespace LibationWinForms.GridView { Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay)); } - } #endregion diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 24dafc73..b762a046 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -10,23 +10,24 @@ using System.Windows.Forms; namespace LibationWinForms.GridView { + public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry); + public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry); + public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle); + public partial class ProductsGrid : UserControl { - public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry); - public delegate void LibraryBookEntryRectangleClickedEventHandler(LibraryBookEntry liveGridEntry, Rectangle cellRectangle); - /// Number of visible rows has changed public event EventHandler VisibleCountChanged; public event LibraryBookEntryClickedEventHandler LiberateClicked; - public event LibraryBookEntryClickedEventHandler CoverClicked; + public event GridEntryClickedEventHandler CoverClicked; public event LibraryBookEntryClickedEventHandler DetailsClicked; - public event LibraryBookEntryRectangleClickedEventHandler DescriptionClicked; + public event GridEntryRectangleClickedEventHandler DescriptionClicked; public new event EventHandler Scroll; private GridEntryBindingList bindingList; internal IEnumerable GetVisible() => bindingList - .LibraryBooks(); + .BookEntries(); public ProductsGrid() { @@ -61,16 +62,23 @@ namespace LibationWinForms.GridView else if (e.ColumnIndex == coverGVColumn.Index) CoverClicked?.Invoke(lbEntry); } - else if (entry is SeriesEntry sEntry && e.ColumnIndex == liberateGVColumn.Index) + else if (entry is SeriesEntry sEntry) { - if (sEntry.Liberate.Expanded) - bindingList.CollapseItem(sEntry); - else - bindingList.ExpandItem(sEntry); + if (e.ColumnIndex == liberateGVColumn.Index) + { + if (sEntry.Liberate.Expanded) + bindingList.CollapseItem(sEntry); + else + bindingList.ExpandItem(sEntry); - sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate)); + sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate)); - VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + } + else if (e.ColumnIndex == descriptionGVColumn.Index) + DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); + else if (e.ColumnIndex == coverGVColumn.Index) + CoverClicked?.Invoke(sEntry); } } @@ -82,14 +90,18 @@ namespace LibationWinForms.GridView internal void BindToGrid(List dbBooks) { - var geList = dbBooks.Where(b => b.Book.ContentType is not ContentType.Episode).Select(b => new LibraryBookEntry(b)).Cast().ToList(); + var geList = dbBooks.Where(lb => lb.IsProduct()).Select(b => new LibraryBookEntry(b)).Cast().ToList(); - var episodes = dbBooks.Where(b => b.IsEpisodeChild()).ToList(); - - var allSeries = episodes.SelectMany(lb => lb.Book.SeriesLink.Where(s => !s.Series.AudibleSeriesId.StartsWith("SERIES_"))).DistinctBy(s => s.Series).ToList(); - foreach (var series in allSeries) + var parents = dbBooks.Where(lb => lb.IsEpisodeParent()); + var episodes = dbBooks.Where(lb => lb.IsEpisodeChild()); + + foreach (var parent in parents) { - var seriesEntry = new SeriesEntry(series, episodes.Where(lb => lb.Book.SeriesLink.Any(s => s.Series == series.Series))); + var seriesEpisodes = episodes.Where(lb => lb.Book.SeriesLink?.Any(s => s.Series.AudibleSeriesId == parent.Book.AudibleProductId) == true).ToList(); + + if (!seriesEpisodes.Any()) continue; + + var seriesEntry = new SeriesEntry(parent, seriesEpisodes); geList.Add(seriesEntry); geList.AddRange(seriesEntry.Children); @@ -98,79 +110,47 @@ namespace LibationWinForms.GridView bindingList = new GridEntryBindingList(geList.OrderByDescending(e => e.DateAdded)); bindingList.CollapseAll(); syncBindingSource.DataSource = bindingList; - VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); - } + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + } internal void UpdateGrid(List dbBooks) { + #region Add new or update existing grid entries + + //Remove filter prior to adding/updating boooks string existingFilter = syncBindingSource.Filter; Filter(null); bindingList.SuspendFilteringOnUpdate = true; - //Add absent books to grid, or update current books + //Add absent entries to grid, or update existing entry - var allItmes = bindingList.AllItems().LibraryBooks(); - foreach (var libraryBook in dbBooks) + var allEntries = bindingList.AllItems().BookEntries(); + var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); + + foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) { - var existingItem = allItmes.FindBookByAsin(libraryBook.Book.AudibleProductId); + var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); - // add new to top - if (existingItem is null) - { - if (libraryBook.IsEpisodeChild()) - { - LibraryBookEntry lbe; - //Find the series that libraryBook belongs to, if it exists - var series = bindingList.AllItems().FindBookSeriesEntry(libraryBook.Book.SeriesLink); - - if (series is null) - { - //Series doesn't exist yet, so create and add it - var newSeries = new SeriesEntry(libraryBook.Book.SeriesLink.First(), libraryBook); - lbe = newSeries.Children[0]; - newSeries.Liberate.Expanded = true; - bindingList.Insert(0, newSeries); - series = newSeries; - } - else - { - lbe = new(libraryBook) { Parent = series }; - series.Children.Add(lbe); - } - //Add episode beneath the parent - int seriesIndex = bindingList.IndexOf(series); - bindingList.Insert(seriesIndex + 1, lbe); - - if (series.Liberate.Expanded) - bindingList.ExpandItem(series); - else - bindingList.CollapseItem(series); - - series.NotifyPropertyChanged(); - } - else if (libraryBook.Book.ContentType is not ContentType.Episode) - //Add the new product - bindingList.Insert(0, new LibraryBookEntry(libraryBook)); - } - // update existing - else - { - existingItem.UpdateLibraryBook(libraryBook); - } - } + if (libraryBook.IsEpisodeChild()) + AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); + else if (libraryBook.IsProduct()) + AddOrUpdateBook(libraryBook, existingEntry); + } bindingList.SuspendFilteringOnUpdate = false; - //Re-filter after updating existing / adding new books to capture any changes + //Re-apply filter after adding new/updating existing books to capture any changes Filter(existingFilter); + #endregion + // remove deleted from grid. // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this var removedBooks = bindingList .AllItems() - .LibraryBooks() + .BookEntries() .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); //Remove books in series from their parents' Children list @@ -190,7 +170,69 @@ namespace LibationWinForms.GridView //no need to re-filter for removed books bindingList.Remove(removed); - VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + } + + private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry existingBookEntry) + { + if (existingBookEntry is null) + // Add the new product to top + bindingList.Insert(0, new LibraryBookEntry(book)); + else + // update existing + existingBookEntry.UpdateLibraryBook(book); + } + + private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) + { + if (existingEpisodeEntry is null) + { + LibraryBookEntry episodeEntry; + var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); + + if (seriesEntry is null) + { + //Series doesn't exist yet, so create and add it + var seriesBook = dbBooks.FindSeriesParent(episodeBook); + + if (seriesBook is null) + { + var ex = new ApplicationException($"Episode's series parent not found in database."); + var seriesLinks = string.Join("\r\n", episodeBook.Book.SeriesLink?.Select(sb => $"{nameof(sb.Series.Name)}={sb.Series.Name}, {nameof(sb.Series.AudibleSeriesId)}={sb.Series.AudibleSeriesId}")); + Serilog.Log.Logger.Error(ex, "Episode={episodeBook}, Series: {seriesLinks}", episodeBook, seriesLinks); + throw ex; + } + + seriesEntry = new SeriesEntry(seriesBook, episodeBook); + seriesEntries.Add(seriesEntry); + + episodeEntry = seriesEntry.Children[0]; + seriesEntry.Liberate.Expanded = true; + bindingList.Insert(0, seriesEntry); + } + else + { + //Series exists. Create and add episode child then update the SeriesEntry + episodeEntry = new(episodeBook) { Parent = seriesEntry }; + seriesEntry.Children.Add(episodeEntry); + var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); + seriesEntry.UpdateSeries(seriesBook); + } + + //Add episode to the grid beneath the parent + int seriesIndex = bindingList.IndexOf(seriesEntry); + bindingList.Insert(seriesIndex + 1, episodeEntry); + + if (seriesEntry.Liberate.Expanded) + bindingList.ExpandItem(seriesEntry); + else + bindingList.CollapseItem(seriesEntry); + + seriesEntry.NotifyPropertyChanged(); + + } + else + existingEpisodeEntry.UpdateLibraryBook(episodeBook); } #endregion @@ -207,7 +249,7 @@ namespace LibationWinForms.GridView syncBindingSource.Filter = searchString; if (visibleCount != bindingList.Count) - VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); } diff --git a/Source/LibationWinForms/GridView/QueryExtensions.cs b/Source/LibationWinForms/GridView/QueryExtensions.cs new file mode 100644 index 00000000..e6df05cb --- /dev/null +++ b/Source/LibationWinForms/GridView/QueryExtensions.cs @@ -0,0 +1,58 @@ +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 bool IsProduct(this LibraryBook lb) + => lb.Book.ContentType is not ContentType.Episode and not ContentType.Parent; + + public static bool IsEpisodeChild(this LibraryBook lb) + => lb.Book.ContentType is ContentType.Episode; + + public static bool IsEpisodeParent(this LibraryBook lb) + => lb.Book.ContentType is ContentType.Parent; + + public static SeriesEntry? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode) + { + if (seriesEpisode.Book.SeriesLink is null) return null; + + //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)); + } + + public static LibraryBook? FindSeriesParent(this IEnumerable libraryBooks, LibraryBook seriesEpisode) + { + if (seriesEpisode.Book.SeriesLink is null) return null; + + //Parent books will always have exactly 1 SeriesBook due to how + //they are imported in ApiExtended.getChildEpisodesAsync() + return libraryBooks.FirstOrDefault( + lb => + lb.IsEpisodeParent() && + seriesEpisode.Book.SeriesLink.Any( + s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId)); + } + } +#nullable disable +} diff --git a/Source/LibationWinForms/GridView/SeriesEntry.cs b/Source/LibationWinForms/GridView/SeriesEntry.cs index 96d40f49..781695f0 100644 --- a/Source/LibationWinForms/GridView/SeriesEntry.cs +++ b/Source/LibationWinForms/GridView/SeriesEntry.cs @@ -6,101 +6,79 @@ using System.Linq; namespace LibationWinForms.GridView { + /// The View Model for a LibraryBook that is ContentType.Parent public class SeriesEntry : GridEntry { - public List Children { get; init; } + public List Children { get; } = new(); public override DateTime DateAdded => Children.Max(c => c.DateAdded); - public override float SeriesIndex { get; } - public override string ProductRating - { - get - { - var productAverageRating = new Rating(Children.Average(c => c.LibraryBook.Book.Rating.OverallRating), Children.Average(c => c.LibraryBook.Book.Rating.PerformanceRating), Children.Average(c => c.LibraryBook.Book.Rating.StoryRating)); - return productAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - } - protected set => throw new NotImplementedException(); - } - public override string PurchaseDate { get; protected set; } - public override string MyRating - { - get - { - var myAverageRating = new Rating(Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.OverallRating), Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.PerformanceRating), Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.StoryRating)); - return myAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - } - protected set => throw new NotImplementedException(); - } - public override string Series { get; protected set; } - public override string Title { get; protected set; } - public override string Length - { - get - { - int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); - return bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; - } - protected set => throw new NotImplementedException(); - } - public override string Authors { get; protected set; } - public override string Narrators { get; protected set; } - public override string Category { get; protected set; } - public override string Misc { get; protected set; } = string.Empty; - public override string Description { get; protected set; } = string.Empty; public override string DisplayTags { get; } = string.Empty; - public override LiberateButtonStatus Liberate { get; } - protected override Book Book => SeriesBook.Book; - - private SeriesBook SeriesBook { get; set; } - - private SeriesEntry(SeriesBook seriesBook) + private SeriesEntry(LibraryBook parent) { + LibraryBook = parent; Liberate = new LiberateButtonStatus { IsSeries = true }; - SeriesIndex = seriesBook.Index; + SeriesIndex = -1; } - public SeriesEntry(SeriesBook seriesBook, IEnumerable children) : this(seriesBook) + + public SeriesEntry(LibraryBook parent, IEnumerable children) : this(parent) { - Children = children.Select(c => new LibraryBookEntry(c) { Parent = this }).OrderBy(c => c.SeriesIndex).ToList(); - SetSeriesBook(seriesBook); + Children = children + .Select(c => new LibraryBookEntry(c) { Parent = this }) + .OrderBy(c => c.SeriesIndex) + .ToList(); + + UpdateSeries(parent); + LoadCover(); } - public SeriesEntry(SeriesBook seriesBook, LibraryBook child) : this(seriesBook) + + public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent) { Children = new() { new LibraryBookEntry(child) { Parent = this } }; - SetSeriesBook(seriesBook); + + UpdateSeries(parent); + LoadCover(); } - private void SetSeriesBook(SeriesBook seriesBook) + public void UpdateSeries(LibraryBook libraryBook) { - SeriesBook = seriesBook; - LoadCover(); + LibraryBook = libraryBook; // Immutable properties { - Title = SeriesBook.Series.Name; - Series = SeriesBook.Series.Name; + Title = Book.Title; + Series = Book.SeriesNames(); + MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); + ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); Authors = Book.AuthorNames(); Narrators = Book.NarratorNames(); 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(); + } /// Create getters for all member object values by name protected override Dictionary> CreateMemberValueDictionary() => new() { - { nameof(Title), () => Book.SeriesSortable() }, + { nameof(Title), () => Book.TitleSortable() }, { nameof(Series), () => Book.SeriesSortable() }, { nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) }, - { nameof(MyRating), () => Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.FirstScore()) }, + { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, { nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) }, - { nameof(ProductRating), () => Children.Average(c => c.LibraryBook.Book.Rating.FirstScore()) }, - { nameof(Authors), () => string.Empty }, - { nameof(Narrators), () => string.Empty }, - { nameof(Description), () => string.Empty }, - { nameof(Category), () => string.Empty }, - { nameof(Misc), () => string.Empty }, + { nameof(ProductRating), () => Book.Rating.FirstScore() }, + { nameof(Authors), () => Authors }, + { nameof(Narrators), () => Narrators }, + { nameof(Description), () => Description }, + { nameof(Category), () => Category }, + { nameof(Misc), () => Misc }, { nameof(DisplayTags), () => string.Empty }, { nameof(Liberate), () => Liberate }, { nameof(DateAdded), () => DateAdded },