diff --git a/Source/AppScaffolding/AppScaffolding.csproj b/Source/AppScaffolding/AppScaffolding.csproj index 37d7e6ca..db37ad59 100644 --- a/Source/AppScaffolding/AppScaffolding.csproj +++ b/Source/AppScaffolding/AppScaffolding.csproj @@ -3,7 +3,7 @@ net6.0-windows - 7.7.1.1 + 7.7.0.14 diff --git a/Source/DataLayer/EfClasses/Rating.cs b/Source/DataLayer/EfClasses/Rating.cs index 761633be..18b0cd05 100644 --- a/Source/DataLayer/EfClasses/Rating.cs +++ b/Source/DataLayer/EfClasses/Rating.cs @@ -12,7 +12,7 @@ namespace DataLayer public float StoryRating { get; private set; } private Rating() { } - internal Rating(float overallRating, float performanceRating, float storyRating) + public Rating(float overallRating, float performanceRating, float storyRating) { OverallRating = overallRating; PerformanceRating = performanceRating; diff --git a/Source/LibationWinForms/Dialogs/RemoveBooksDialog.cs b/Source/LibationWinForms/Dialogs/RemoveBooksDialog.cs index ecd56188..bb1759dc 100644 --- a/Source/LibationWinForms/Dialogs/RemoveBooksDialog.cs +++ b/Source/LibationWinForms/Dialogs/RemoveBooksDialog.cs @@ -121,10 +121,8 @@ namespace LibationWinForms.Dialogs } } - internal class RemovableGridEntry : GridEntry + internal class RemovableGridEntry : LibraryBookEntry { - private static readonly IComparer BoolComparer = new ObjectComparer(); - private bool _remove = false; public RemovableGridEntry(LibraryBook libraryBook) : base(libraryBook) { } @@ -147,12 +145,5 @@ namespace LibationWinForms.Dialogs return Remove; return base.GetMemberValue(memberName); } - - public override IComparer GetMemberComparer(Type memberType) - { - if (memberType == typeof(bool)) - return BoolComparer; - return base.GetMemberComparer(memberType); - } } } diff --git a/Source/LibationWinForms/Form1.cs b/Source/LibationWinForms/Form1.cs index 04150167..0c2f3ce1 100644 --- a/Source/LibationWinForms/Form1.cs +++ b/Source/LibationWinForms/Form1.cs @@ -12,7 +12,7 @@ namespace LibationWinForms { public partial class Form1 : Form { - private ProductsGrid productsGrid { get; } + private ProductsDisplay productsGrid { get; } public Form1() { @@ -26,7 +26,7 @@ namespace LibationWinForms // Failed to create component 'ProductsGrid'. The error message follows: // 'Microsoft.DotNet.DesignTools.Client.DesignToolsServerException: Object reference not set to an instance of an object. // Since the designer's choking on it, I'm keeping it below the DesignMode check to be safe - productsGrid = new ProductsGrid { Dock = DockStyle.Fill }; + productsGrid = new ProductsDisplay { Dock = DockStyle.Fill }; gridPanel.Controls.Add(productsGrid); } diff --git a/Source/LibationWinForms/LibationWinForms.csproj b/Source/LibationWinForms/LibationWinForms.csproj index 2ecd019b..979db0ff 100644 --- a/Source/LibationWinForms/LibationWinForms.csproj +++ b/Source/LibationWinForms/LibationWinForms.csproj @@ -45,6 +45,9 @@ + + UserControl + True True @@ -53,6 +56,9 @@ + + Designer + ResXFileCodeGenerator Resources.Designer.cs diff --git a/Source/LibationWinForms/Properties/Resources.Designer.cs b/Source/LibationWinForms/Properties/Resources.Designer.cs index 468d9e4d..f3e881e2 100644 --- a/Source/LibationWinForms/Properties/Resources.Designer.cs +++ b/Source/LibationWinForms/Properties/Resources.Designer.cs @@ -229,5 +229,25 @@ namespace LibationWinForms.Properties { return ((System.Drawing.Bitmap)(obj)); } } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap minus { + get { + object obj = ResourceManager.GetObject("minus", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap plus { + get { + object obj = ResourceManager.GetObject("plus", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } } } diff --git a/Source/LibationWinForms/Properties/Resources.resx b/Source/LibationWinForms/Properties/Resources.resx index 0a1ae67a..95b3b8a0 100644 --- a/Source/LibationWinForms/Properties/Resources.resx +++ b/Source/LibationWinForms/Properties/Resources.resx @@ -169,4 +169,10 @@ ..\Resources\liberate_yellow_pdf_yes.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + ..\Resources\minus.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\plus.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + \ No newline at end of file diff --git a/Source/LibationWinForms/Resources/minus.png b/Source/LibationWinForms/Resources/minus.png new file mode 100644 index 00000000..bfc2cf4a Binary files /dev/null and b/Source/LibationWinForms/Resources/minus.png differ diff --git a/Source/LibationWinForms/Resources/plus.png b/Source/LibationWinForms/Resources/plus.png new file mode 100644 index 00000000..1cae3d6b Binary files /dev/null and b/Source/LibationWinForms/Resources/plus.png differ diff --git a/Source/LibationWinForms/grid/FilterableSortableBindingList.cs b/Source/LibationWinForms/grid/FilterableSortableBindingList.cs index 1e896e0b..e3bcaad0 100644 --- a/Source/LibationWinForms/grid/FilterableSortableBindingList.cs +++ b/Source/LibationWinForms/grid/FilterableSortableBindingList.cs @@ -19,14 +19,16 @@ namespace LibationWinForms * Remove is overridden to ensure that removed items are removed from * the base list (visible items) as well as the FilterRemoved list. */ - internal class FilterableSortableBindingList : SortableBindingList, IBindingListView + internal class FilterableSortableBindingList : SortableBindingList1, IBindingListView { /// /// Items that were removed from the base list due to filtering /// private readonly List FilterRemoved = new(); private string FilterString; + private LibationSearchEngine.SearchResultSet SearchResults; public FilterableSortableBindingList(IEnumerable enumeration) : base(enumeration) { } + public FilterableSortableBindingList() : base(new List()) { } public bool SupportsFiltering => true; public string Filter { get => FilterString; set => ApplyFilter(value); } @@ -48,7 +50,14 @@ namespace LibationWinForms } /// All items in the list, including those filtered out. - public List AllItems() => Items.Concat(FilterRemoved).ToList(); + public List AllItems() + { + var allItems = Items.Concat(FilterRemoved); + + var series = allItems.Where(i => i is SeriesEntry).Cast().SelectMany(s => s.Children); + + return series.Concat(allItems).ToList(); + } private void ApplyFilter(string filterString) { @@ -57,18 +66,49 @@ namespace LibationWinForms FilterString = filterString; - var searchResults = SearchEngineCommands.Search(filterString); - var filteredOut = Items.ExceptBy(searchResults.Docs.Select(d => d.ProductId), ge => ge.AudibleProductId); + SearchResults = SearchEngineCommands.Search(filterString); + var filteredOut = Items.Where(i => i is LibraryBookEntry).Cast().ExceptBy(SearchResults.Docs.Select(d => d.ProductId), ge => ge.AudibleProductId).Cast().ToList(); - for (int i = Items.Count - 1; i >= 0; i--) + var parents = Items.Where(i => i is SeriesEntry).Cast(); + + foreach (var p in parents) { - if (filteredOut.Contains(Items[i])) + if (p.Children.Cast().ExceptBy(SearchResults.Docs.Select(d => d.ProductId), ge => ge.AudibleProductId).Count() == p.Children.Count) { - FilterRemoved.Add(Items[i]); - Items.RemoveAt(i); - base.OnListChanged(new ListChangedEventArgs(ListChangedType.ItemDeleted, i)); + //Don't show series whose episodes have all been filtered out + filteredOut.Add(p); } } + + for (int i = 0; i < filteredOut.Count; i++) + { + FilterRemoved.Add(filteredOut[i]); + base.Remove(filteredOut[i]); + } + } + + public void CollapseItem(SeriesEntry sEntry) + { + foreach (var item in Items.Where(b => b is LibraryBookEntry).Cast().Where(b => b.Parent == sEntry).ToList()) + base.Remove(item); + + sEntry.Liberate.Expanded = false; + } + + public void ExpandItem(SeriesEntry sEntry) + { + var sindex = Items.IndexOf(sEntry); + var children = sEntry.Children.Cast().ToList(); + for (int i = 0; i < children.Count; i++) + { + if (SearchResults is null || SearchResults.Docs.Any(d=> d.ProductId == children[i].AudibleProductId)) + Insert(++sindex, children[i]); + else + { + FilterRemoved.Add(children[i]); + } + } + sEntry.Liberate.Expanded = true; } public void RemoveFilter() @@ -77,18 +117,27 @@ namespace LibationWinForms int visibleCount = Items.Count; for (int i = 0; i < FilterRemoved.Count; i++) - base.InsertItem(i + visibleCount, FilterRemoved[i]); - OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + { + if (FilterRemoved[i].Parent is null || FilterRemoved[i].Parent.Liberate.Expanded) + base.InsertItem(i + visibleCount, FilterRemoved[i]); + } FilterRemoved.Clear(); if (IsSortedCore) Sort(); else - //No user-defined sort is applied, so do default sorting by date added, descending - ((List)Items).Sort((i1, i2) => i2.LibraryBook.DateAdded.CompareTo(i1.LibraryBook.DateAdded)); + //No user sort is applied, so do default sorting by PurchaseDate, descending + { + Comparer.PropertyName = nameof(GridEntry.DateAdded); + Comparer.Direction = ListSortDirection.Descending; + Sort(); + } + + OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); FilterString = null; + SearchResults = null; } } } diff --git a/Source/LibationWinForms/grid/GridEntry.cs b/Source/LibationWinForms/grid/GridEntry.cs index 3e02dc93..a9d86b41 100644 --- a/Source/LibationWinForms/grid/GridEntry.cs +++ b/Source/LibationWinForms/grid/GridEntry.cs @@ -1,101 +1,65 @@ -using System; +using DataLayer; +using Dinah.Core.DataBinding; +using Dinah.Core.Drawing; +using LibationFileManager; +using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel; using System.Drawing; -using System.Linq; -using ApplicationServices; -using DataLayer; -using Dinah.Core.DataBinding; -using Dinah.Core; -using Dinah.Core.Drawing; -using LibationFileManager; -using System.Threading.Tasks; namespace LibationWinForms { - /// - /// The View Model for a LibraryBook - /// - internal class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable + public interface IHierarchical where T : class { - #region implementation properties NOT exposed to the view - // hide from public fields from Data Source GUI with [Browsable(false)] + T Parent { get; } + List Children { get; } + } + internal class LiberateStatus + { + public LiberatedStatus BookStatus; + public LiberatedStatus? PdfStatus; + public bool IsSeries; + public bool Expanded; + } - [Browsable(false)] - public string AudibleProductId => Book.AudibleProductId; - [Browsable(false)] - public LibraryBook LibraryBook { get; private set; } - [Browsable(false)] - public string LongDescription { get; private set; } - #endregion + internal abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable, IHierarchical + { + protected abstract Book Book { get; } - #region Model properties exposed to the view private Image _cover; - - private DateTime lastStatusUpdate = default; - private LiberatedStatus _bookStatus; - private LiberatedStatus? _pdfStatus; + #region Model properties exposed to the view public Image Cover { get => _cover; - private set + protected set { _cover = value; NotifyPropertyChanged(); } } - - public string ProductRating { get; private set; } - public string PurchaseDate { get; private set; } - public string MyRating { get; private set; } - public string Series { get; private set; } - public string Title { get; private set; } - public string Length { get; private set; } - public string Authors { get; private set; } - public string Narrators { get; private set; } - public string Category { get; private set; } - public string Misc { get; private set; } - public string Description { get; private set; } - public 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 (LiberatedStatus BookStatus, LiberatedStatus? PdfStatus) 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 (_bookStatus, _pdfStatus); - } - } + public GridEntry Parent { get; set; } + public List Children { get; set; } + public abstract string ProductRating { get; protected set; } + public abstract string PurchaseDate { get; protected set; } + public abstract DateTime DateAdded { get; } + 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 LiberateStatus Liberate { get; } + public abstract object GetMemberValue(string memberName); #endregion + public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType]; - // alias - private Book Book => LibraryBook.Book; - - public GridEntry(LibraryBook libraryBook) => setLibraryBook(libraryBook); - - public void UpdateLibraryBook(LibraryBook libraryBook) + protected void LoadCover() { - if (AudibleProductId != libraryBook.Book.AudibleProductId) - throw new Exception("Invalid grid entry update. IDs must match"); - - setLibraryBook(libraryBook); - - NotifyPropertyChanged(); - } - - private void setLibraryBook(LibraryBook libraryBook) - { - LibraryBook = libraryBook; - _memberValues = CreateMemberValueDictionary(); - // Get cover art. If it's default, subscribe to PictureCached { (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); @@ -106,24 +70,6 @@ namespace LibationWinForms // Mutable property. Set the field so PropertyChanged isn't fired. _cover = ImageReader.ToImage(picture); } - - // Immutable properties - { - Title = Book.Title; - Series = Book.SeriesNames(); - Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min"; - MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - PurchaseDate = 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); - } - - UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; } private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) @@ -135,154 +81,19 @@ namespace LibationWinForms } } - #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. - /// Save to the database and 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; - - 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.PdfStatus = udi.PdfStatus; - _pdfStatus = udi.PdfStatus; - NotifyPropertyChanged(nameof(Liberate)); - break; - } - } - - /// Save edits to the database - public void Commit(string newTags, LiberatedStatus bookStatus, LiberatedStatus? pdfStatus) - { - // validate - if (DisplayTags.EqualsInsensitive(newTags) && - Liberate.BookStatus == bookStatus && - Liberate.PdfStatus == pdfStatus) - return; - - // update cache - _bookStatus = bookStatus; - _pdfStatus = pdfStatus; - - // set + save - Book.UserDefinedItem.Tags = newTags; - Book.UserDefinedItem.BookStatus = bookStatus; - Book.UserDefinedItem.PdfStatus = pdfStatus; - LibraryCommands.UpdateUserDefinedItem(Book); - } - - #endregion - - #region Data Sorting - // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable - // Used by Dinah.Core.DataBinding.SortableBindingList for all sorting - public virtual object GetMemberValue(string memberName) => _memberValues[memberName](); - public virtual IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType]; - - private Dictionary> _memberValues { get; set; } - - /// - /// Create getters for all member object values by name - /// - private Dictionary> CreateMemberValueDictionary() => new() - { - { 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(DisplayTags), () => DisplayTags }, - { nameof(Liberate), () => Liberate.BookStatus } - }; - // 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(LiberatedStatus), new ObjectComparer() }, }; - #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() { - UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; PictureStorage.PictureCached -= PictureStorage_PictureCached; } } diff --git a/Source/LibationWinForms/grid/LiberateDataGridViewImageButtonColumn.cs b/Source/LibationWinForms/grid/LiberateDataGridViewImageButtonColumn.cs index e5ab82b8..159b1e4a 100644 --- a/Source/LibationWinForms/grid/LiberateDataGridViewImageButtonColumn.cs +++ b/Source/LibationWinForms/grid/LiberateDataGridViewImageButtonColumn.cs @@ -20,15 +20,27 @@ namespace LibationWinForms { base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); - if (value is (LiberatedStatus, LiberatedStatus) or (LiberatedStatus, null)) + if (value is LiberateStatus status) { - var (bookState, pdfState) = ((LiberatedStatus bookState, LiberatedStatus? pdfState))value; + if (status.IsSeries) + { + var imageName = status.Expanded ? "minus" : "plus"; + var text = status.Expanded ? "Click to Collpase" : "Click to Expand"; - (string mouseoverText, Bitmap buttonImage) = GetLiberateDisplay(bookState, pdfState); + var bmp = (Bitmap)Properties.Resources.ResourceManager.GetObject(imageName); + DrawButtonImage(graphics, bmp, cellBounds); - DrawButtonImage(graphics, buttonImage, cellBounds); + ToolTipText = text; - ToolTipText = mouseoverText; + } + else + { + (string mouseoverText, Bitmap buttonImage) = GetLiberateDisplay(status.BookStatus, status.PdfStatus); + + DrawButtonImage(graphics, buttonImage, cellBounds); + + ToolTipText = mouseoverText; + } } } diff --git a/Source/LibationWinForms/grid/LibraryBookEntry.cs b/Source/LibationWinForms/grid/LibraryBookEntry.cs new file mode 100644 index 00000000..e87ae630 --- /dev/null +++ b/Source/LibationWinForms/grid/LibraryBookEntry.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Linq; +using ApplicationServices; +using DataLayer; +using Dinah.Core.DataBinding; +using Dinah.Core; +using Dinah.Core.Drawing; +using LibationFileManager; +using System.Threading.Tasks; + +namespace LibationWinForms +{ + /// + /// The View Model for a LibraryBook + /// + internal 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 + + // alias + protected override Book Book => LibraryBook.Book; + #region Model properties exposed to the view + + private DateTime lastStatusUpdate = default; + private LiberatedStatus _bookStatus; + private LiberatedStatus? _pdfStatus; + + public override DateTime DateAdded => LibraryBook.DateAdded; + 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 LiberateStatus 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 LiberateStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false }; + } + } + #endregion + + + public LibraryBookEntry(LibraryBook libraryBook) => setLibraryBook(libraryBook); + + public void UpdateLibraryBook(LibraryBook libraryBook) + { + if (AudibleProductId != libraryBook.Book.AudibleProductId) + throw new Exception("Invalid grid entry update. IDs must match"); + + setLibraryBook(libraryBook); + + NotifyPropertyChanged(); + } + + private void setLibraryBook(LibraryBook libraryBook) + { + LibraryBook = libraryBook; + _memberValues = CreateMemberValueDictionary(); + + LoadCover(); + + // Immutable properties + { + Title = Book.Title; + Series = Book.SeriesNames(); + Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min"; + MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + PurchaseDate = 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); + } + + 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. + /// Save to the database and 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; + + 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.PdfStatus = udi.PdfStatus; + _pdfStatus = udi.PdfStatus; + NotifyPropertyChanged(nameof(Liberate)); + break; + } + } + + /// Save edits to the database + public void Commit(string newTags, LiberatedStatus bookStatus, LiberatedStatus? pdfStatus) + { + // validate + if (DisplayTags.EqualsInsensitive(newTags) && + Liberate.BookStatus == bookStatus && + Liberate.PdfStatus == pdfStatus) + return; + + // update cache + _bookStatus = bookStatus; + _pdfStatus = pdfStatus; + + // set + save + Book.UserDefinedItem.Tags = newTags; + Book.UserDefinedItem.BookStatus = bookStatus; + Book.UserDefinedItem.PdfStatus = pdfStatus; + LibraryCommands.UpdateUserDefinedItem(Book); + } + + #endregion + + #region Data Sorting + // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable + // Used by Dinah.Core.DataBinding.SortableBindingList for all sorting + public override object GetMemberValue(string memberName) => _memberValues[memberName](); + + private Dictionary> _memberValues { get; set; } + + /// + /// Create getters for all member object values by name + /// + private Dictionary> CreateMemberValueDictionary() => new() + { + { 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(DisplayTags), () => DisplayTags }, + { nameof(Liberate), () => Liberate.BookStatus }, + { 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() + { + UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; + } + } +} diff --git a/Source/LibationWinForms/grid/MasterDataGridView.cs b/Source/LibationWinForms/grid/MasterDataGridView.cs new file mode 100644 index 00000000..fd3b481a --- /dev/null +++ b/Source/LibationWinForms/grid/MasterDataGridView.cs @@ -0,0 +1,26 @@ +using Dinah.Core.Windows.Forms; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace LibationWinForms +{ + + internal class MasterDataGridView : DataGridView + { + internal delegate void LibraryBookEntryClickedEventHandler(DataGridViewCellEventArgs e, LibraryBookEntry entry); + public event LibraryBookEntryClickedEventHandler LibraryBookEntryClicked; + public MasterDataGridView() + { + + } + + + public GridEntry getGridEntry(int rowIndex) => this.GetBoundItem(rowIndex); + + } +} diff --git a/Source/LibationWinForms/grid/ProductsDisplay.Designer.cs b/Source/LibationWinForms/grid/ProductsDisplay.Designer.cs new file mode 100644 index 00000000..d15ad85c --- /dev/null +++ b/Source/LibationWinForms/grid/ProductsDisplay.Designer.cs @@ -0,0 +1,46 @@ +namespace LibationWinForms +{ + partial class ProductsDisplay + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.SuspendLayout(); + // + // ProductsDisplay + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + this.Name = "ProductsDisplay"; + this.Size = new System.Drawing.Size(1510, 380); + this.ResumeLayout(false); + + } + + #endregion + } +} diff --git a/Source/LibationWinForms/grid/ProductsDisplay.cs b/Source/LibationWinForms/grid/ProductsDisplay.cs new file mode 100644 index 00000000..e0e0fd58 --- /dev/null +++ b/Source/LibationWinForms/grid/ProductsDisplay.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using ApplicationServices; +using DataLayer; +using Dinah.Core.Windows.Forms; +using FileLiberator; +using LibationFileManager; +using LibationWinForms.Dialogs; + +namespace LibationWinForms +{ + + #region // legacy instructions to update data_grid_view + // INSTRUCTIONS TO UPDATE DATA_GRID_VIEW + // - delete current DataGridView + // - view > other windows > data sources + // - refresh + // OR + // - Add New Data Source + // Object. Next + // LibationWinForms + // AudibleDTO + // GridEntry + // - go to Design view + // - click on Data Sources > ProductItem. dropdown: DataGridView + // - drag/drop ProductItem on design surface + // + // as of august 2021 this does not work in vs2019 with .net5 projects + // VS has improved since then with .net6+ but I haven't checked again + #endregion + + + public partial class ProductsDisplay : UserControl + { + public event EventHandler LiberateClicked; + /// Number of visible rows has changed + public event EventHandler VisibleCountChanged; + + // alias + + private ProductsGrid grid; + + public ProductsDisplay() + { + InitializeComponent(); + + grid = new ProductsGrid(); + grid.Dock = DockStyle.Fill; + Controls.Add(grid); + + if (this.DesignMode) + return; + + grid.LiberateClicked += (_, book) => LiberateClicked?.Invoke(this, book.LibraryBook); + grid.DetailsClicked += Grid_DetailsClicked; + grid.CoverClicked += Grid_CoverClicked; + grid.DescriptionClicked += Grid_DescriptionClicked1; + } + + #region Button controls + + private ImageDisplay imageDisplay; + private async void Grid_CoverClicked(DataGridViewCellEventArgs e, LibraryBookEntry liveGridEntry) + { + var picDefinition = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native); + var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); + + (_, byte[] initialImageBts) = PictureStorage.GetPicture(new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureId, PictureSize._80x80)); + var windowTitle = $"{liveGridEntry.Title} - Cover"; + + if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) + { + imageDisplay = new ImageDisplay(); + imageDisplay.RestoreSizeAndLocation(Configuration.Instance); + imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); + imageDisplay.Show(this); + } + + imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(liveGridEntry.LibraryBook); + imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(liveGridEntry.LibraryBook, ".jpg")); + imageDisplay.Text = windowTitle; + imageDisplay.CoverPicture = initialImageBts; + imageDisplay.CoverPicture = await picDlTask; + } + + private void Grid_DescriptionClicked1(DataGridViewCellEventArgs e, LibraryBookEntry liveGridEntry, Rectangle cellRectangle) + { + var displayWindow = new DescriptionDisplay + { + SpawnLocation = PointToScreen(cellRectangle.Location + new Size(cellRectangle.Width, 0)), + DescriptionText = liveGridEntry.LongDescription, + BorderThickness = 2, + }; + + void CloseWindow(object o, EventArgs e) + { + displayWindow.Close(); + } + + grid.Scroll += CloseWindow; + displayWindow.FormClosed += (_, _) => grid.Scroll -= CloseWindow; + displayWindow.Show(this); + } + + + private void Grid_DetailsClicked(DataGridViewCellEventArgs e, LibraryBookEntry liveGridEntry) + { + var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook); + if (bookDetailsForm.ShowDialog() == DialogResult.OK) + liveGridEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); + } + + #endregion + + #region UI display functions + + private bool hasBeenDisplayed; + public event EventHandler InitialLoaded; + public void Display() + { + // don't return early if lib size == 0. this will not update correctly if all books are removed + var lib = DbContexts.GetLibrary_Flat_NoTracking(); + + if (!hasBeenDisplayed) + { + // bind + grid.bindToGrid(lib); + hasBeenDisplayed = true; + InitialLoaded?.Invoke(this, new()); + VisibleCountChanged?.Invoke(this, grid.GetVisible().Count()); + } + else + grid.updateGrid(lib); + + } + + #endregion + + #region Filter + + public void Filter(string searchString) + => grid.Filter(searchString); + + #endregion + + internal List GetVisible() => grid.GetVisible().ToList(); + } +} diff --git a/Source/LibationWinForms/grid/ProductsDisplay.resx b/Source/LibationWinForms/grid/ProductsDisplay.resx new file mode 100644 index 00000000..be5db7af --- /dev/null +++ b/Source/LibationWinForms/grid/ProductsDisplay.resx @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 81 + + \ No newline at end of file diff --git a/Source/LibationWinForms/grid/ProductsGrid.Designer.cs b/Source/LibationWinForms/grid/ProductsGrid.Designer.cs index b3eaecd5..27496d79 100644 --- a/Source/LibationWinForms/grid/ProductsGrid.Designer.cs +++ b/Source/LibationWinForms/grid/ProductsGrid.Designer.cs @@ -64,20 +64,20 @@ this.gridEntryDataGridView.AutoGenerateColumns = false; this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { - this.liberateGVColumn, - this.coverGVColumn, - this.titleGVColumn, - this.authorsGVColumn, - this.narratorsGVColumn, - this.lengthGVColumn, - this.seriesGVColumn, - this.descriptionGVColumn, - this.categoryGVColumn, - this.productRatingGVColumn, - this.purchaseDateGVColumn, - this.myRatingGVColumn, - this.miscGVColumn, - this.tagAndDetailsGVColumn}); + this.liberateGVColumn, + this.coverGVColumn, + this.titleGVColumn, + this.authorsGVColumn, + this.narratorsGVColumn, + this.lengthGVColumn, + this.seriesGVColumn, + this.descriptionGVColumn, + this.categoryGVColumn, + this.productRatingGVColumn, + this.purchaseDateGVColumn, + this.myRatingGVColumn, + this.miscGVColumn, + this.tagAndDetailsGVColumn}); this.gridEntryDataGridView.ContextMenuStrip = this.contextMenuStrip1; this.gridEntryDataGridView.DataSource = this.gridEntryBindingSource; dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; diff --git a/Source/LibationWinForms/grid/ProductsGrid.cs b/Source/LibationWinForms/grid/ProductsGrid.cs index 690cb00d..2d585f27 100644 --- a/Source/LibationWinForms/grid/ProductsGrid.cs +++ b/Source/LibationWinForms/grid/ProductsGrid.cs @@ -2,14 +2,11 @@ using System.Collections.Generic; using System.Drawing; using System.Linq; -using System.Threading.Tasks; using System.Windows.Forms; using ApplicationServices; using DataLayer; using Dinah.Core.Windows.Forms; -using FileLiberator; using LibationFileManager; -using LibationWinForms.Dialogs; namespace LibationWinForms { @@ -36,7 +33,17 @@ namespace LibationWinForms public partial class ProductsGrid : UserControl { - public event EventHandler LiberateClicked; + + internal delegate void LibraryBookEntryClickedEventHandler(DataGridViewCellEventArgs e, LibraryBookEntry liveGridEntry); + internal delegate void LibraryBookEntryRectangleClickedEventHandler(DataGridViewCellEventArgs e, LibraryBookEntry liveGridEntry, Rectangle cellRectangle); + internal event LibraryBookEntryClickedEventHandler LiberateClicked; + internal event LibraryBookEntryClickedEventHandler CoverClicked; + internal event LibraryBookEntryClickedEventHandler DetailsClicked; + internal event LibraryBookEntryRectangleClickedEventHandler DescriptionClicked; + public new event EventHandler Scroll; + + private FilterableSortableBindingList bindingList; + /// Number of visible rows has changed public event EventHandler VisibleCountChanged; @@ -53,8 +60,14 @@ namespace LibationWinForms EnableDoubleBuffering(); _dataGridView.CellContentClick += DataGridView_CellContentClick; + _dataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s); - this.Load += ProductsGrid_Load; + Load += ProductsGrid_Load; + } + + private void ProductsGrid_Scroll(object sender, ScrollEventArgs e) + { + throw new NotImplementedException(); } private void EnableDoubleBuffering() @@ -66,117 +79,70 @@ namespace LibationWinForms #region Button controls - private async void DataGridView_CellContentClick(object sender, DataGridViewCellEventArgs e) + private void DataGridView_CellContentClick(object sender, DataGridViewCellEventArgs e) { // handle grid button click: https://stackoverflow.com/a/13687844 if (e.RowIndex < 0) return; - if (e.ColumnIndex == liberateGVColumn.Index) - Liberate_Click(getGridEntry(e.RowIndex)); - else if (e.ColumnIndex == tagAndDetailsGVColumn.Index) - Details_Click(getGridEntry(e.RowIndex)); - else if (e.ColumnIndex == descriptionGVColumn.Index) - Description_Click(getGridEntry(e.RowIndex), _dataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); - else if (e.ColumnIndex == coverGVColumn.Index) - await Cover_Click(getGridEntry(e.RowIndex)); - } - - private ImageDisplay imageDisplay; - private async Task Cover_Click(GridEntry liveGridEntry) - { - var picDefinition = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native); - var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); - - (_, byte[] initialImageBts) = PictureStorage.GetPicture(new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureId, PictureSize._80x80)); - var windowTitle = $"{liveGridEntry.Title} - Cover"; - - if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) + var entry = getGridEntry(e.RowIndex); + if (entry is LibraryBookEntry lbEntry) { - imageDisplay = new ImageDisplay(); - imageDisplay.RestoreSizeAndLocation(Configuration.Instance); - imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); - imageDisplay.Show(this); + if (e.ColumnIndex == liberateGVColumn.Index) + LiberateClicked?.Invoke(e, lbEntry); + else if (e.ColumnIndex == tagAndDetailsGVColumn.Index && entry is LibraryBookEntry) + DetailsClicked?.Invoke(e, lbEntry); + else if (e.ColumnIndex == descriptionGVColumn.Index) + DescriptionClicked?.Invoke(e, lbEntry, _dataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); + else if (e.ColumnIndex == coverGVColumn.Index) + CoverClicked?.Invoke(e, lbEntry); } - - imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(liveGridEntry.LibraryBook); - imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(liveGridEntry.LibraryBook, ".jpg")); - imageDisplay.Text = windowTitle; - imageDisplay.CoverPicture = initialImageBts; - imageDisplay.CoverPicture = await picDlTask; - } - - private void Description_Click(GridEntry liveGridEntry, Rectangle cellDisplay) - { - var displayWindow = new DescriptionDisplay + else if (entry is SeriesEntry sEntry && e.ColumnIndex == liberateGVColumn.Index) { - SpawnLocation = PointToScreen(cellDisplay.Location + new Size(cellDisplay.Width, 0)), - DescriptionText = liveGridEntry.LongDescription, - BorderThickness = 2, - }; + if (sEntry.Liberate.Expanded) + bindingList.CollapseItem(sEntry); + else + bindingList.ExpandItem(sEntry); - void CloseWindow(object o, EventArgs e) - { - displayWindow.Close(); + sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate)); } - - _dataGridView.Scroll += CloseWindow; - displayWindow.FormClosed += (_, _) => _dataGridView.Scroll -= CloseWindow; - displayWindow.Show(this); } - private void Liberate_Click(GridEntry liveGridEntry) - { - LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook); - } - - private static void Details_Click(GridEntry liveGridEntry) - { - var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook); - if (bookDetailsForm.ShowDialog() == DialogResult.OK) - liveGridEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); - } + private GridEntry getGridEntry(int rowIndex) => _dataGridView.GetBoundItem(rowIndex); #endregion #region UI display functions - private FilterableSortableBindingList bindingList; - - private bool hasBeenDisplayed; - public event EventHandler InitialLoaded; - public void Display() + internal void bindToGrid(List dbBooks) { - // don't return early if lib size == 0. this will not update correctly if all books are removed - var lib = DbContexts.GetLibrary_Flat_NoTracking(); + var geList = dbBooks.Where(b => b.Book.ContentType is not ContentType.Episode).Select(b => new LibraryBookEntry(b)).Cast().ToList(); - if (!hasBeenDisplayed) + var episodes = dbBooks.Where(b => b.Book.ContentType is ContentType.Episode).ToList(); + + var series = episodes.Select(lb => lb.Book.SeriesLink.First()).DistinctBy(s => s.Series).ToList(); + + foreach (var s in series) { - // bind - bindToGrid(lib); - hasBeenDisplayed = true; - InitialLoaded?.Invoke(this, new()); - VisibleCountChanged?.Invoke(this, bindingList.Count); + var seriesEntry = new SeriesEntry(); + seriesEntry.Children = episodes.Where(lb => lb.Book.SeriesLink.First().Series == s.Book.SeriesLink.First().Series).Select(lb => new LibraryBookEntry(lb) { Parent = seriesEntry }).Cast().ToList(); + + seriesEntry.setSeriesBook(s); + geList.Add(seriesEntry); } - else - updateGrid(lib); - } - - private void bindToGrid(List dbBooks) - { - bindingList = new FilterableSortableBindingList(dbBooks.OrderByDescending(lb => lb.DateAdded).Select(lb => new GridEntry(lb))); + bindingList = new FilterableSortableBindingList(geList.OrderByDescending(ge => ge.DateAdded)); gridEntryBindingSource.DataSource = bindingList; } - private void updateGrid(List dbBooks) + internal void updateGrid(List dbBooks) { int visibleCount = bindingList.Count; string existingFilter = gridEntryBindingSource.Filter; //Add absent books to grid, or update current books - var allItmes = bindingList.AllItems(); + var allItmes = bindingList.AllItems().Where(i => i is LibraryBookEntry).Cast(); for (var i = dbBooks.Count - 1; i >= 0; i--) { var libraryBook = dbBooks[i]; @@ -184,10 +150,37 @@ namespace LibationWinForms // add new to top if (existingItem is null) - bindingList.Insert(0, new GridEntry(libraryBook)); + { + var lb = new LibraryBookEntry(libraryBook); + + if (libraryBook.Book.ContentType is ContentType.Episode) + { + //Find the series that libraryBook, if it exists + var series = bindingList.AllItems().Where(i => i is SeriesEntry).Cast().FirstOrDefault(i => libraryBook.Book.SeriesLink.Any(s => s.Series.Name == i.Series)); + + if (series is null) + { + //Series doesn't exist yet, so create and add it + var newSeries = new SeriesEntry { Children = new List { lb } }; + newSeries.setSeriesBook(libraryBook.Book.SeriesLink.First()); + lb.Parent = newSeries; + newSeries.Liberate.Expanded = true; + bindingList.Insert(0, newSeries); + } + else + { + lb.Parent = series; + series.Children.Add(lb); + } + } + //Add the new product + bindingList.Insert(0, lb); + } // update existing else + { existingItem.UpdateLibraryBook(libraryBook); + } } if (bindingList.Count != visibleCount) @@ -199,13 +192,22 @@ namespace LibationWinForms // 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 = + var removedBooks = bindingList .AllItems() - .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId) - .ToList(); + .Where(i => i is LibraryBookEntry) + .Cast() + .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); - foreach (var removed in removedBooks) + //Remove series that have no children + var removedSeries = + bindingList + .AllItems() + .Where(i => i is SeriesEntry) + .Cast() + .Where(i => removedBooks.Count(r => r.Series == i.Series) == i.Children.Count); + + foreach (var removed in removedBooks.Cast().Concat(removedSeries)) //no need to re-filter for removed books bindingList.Remove(removed); @@ -232,12 +234,11 @@ namespace LibationWinForms #endregion - internal List GetVisible() + internal IEnumerable GetVisible() => bindingList - .Select(row => row.LibraryBook) - .ToList(); - - private GridEntry getGridEntry(int rowIndex) => _dataGridView.GetBoundItem(rowIndex); + .Where(row => row is LibraryBookEntry) + .Cast() + .Select(row => row.LibraryBook); #region Column Customizations @@ -293,8 +294,6 @@ namespace LibationWinForms column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index); } - - base.OnVisibleChanged(e); } private void gridEntryDataGridView_ColumnDisplayIndexChanged(object sender, DataGridViewColumnEventArgs e) diff --git a/Source/LibationWinForms/grid/ProductsGrid.resx b/Source/LibationWinForms/grid/ProductsGrid.resx index 8a560af5..be5db7af 100644 --- a/Source/LibationWinForms/grid/ProductsGrid.resx +++ b/Source/LibationWinForms/grid/ProductsGrid.resx @@ -57,12 +57,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - 17, 17 - - - 197, 17 - 81 diff --git a/Source/LibationWinForms/grid/SeriesEntry.cs b/Source/LibationWinForms/grid/SeriesEntry.cs new file mode 100644 index 00000000..6a1e51b8 --- /dev/null +++ b/Source/LibationWinForms/grid/SeriesEntry.cs @@ -0,0 +1,90 @@ +using DataLayer; +using Dinah.Core; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms +{ + internal class SeriesEntry : GridEntry + { + public override DateTime DateAdded => Children.Max(c => c.DateAdded); + 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.Empty; + + public override LiberateStatus Liberate => _liberate; + + protected override Book Book => SeriesBook.Book; + + private SeriesBook SeriesBook { get; set; } + + private LiberateStatus _liberate = new LiberateStatus { IsSeries = true }; + public void setSeriesBook(SeriesBook seriesBook) + { + SeriesBook = seriesBook; + _memberValues = CreateMemberValueDictionary(); + LoadCover(); + + // Immutable properties + { + var childLB = Children.Cast(); + int bookLenMins = childLB.Sum(c => c.LibraryBook.Book.LengthInMinutes); + + var myAverageRating = new Rating(childLB.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.OverallRating), childLB.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.PerformanceRating), childLB.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.StoryRating)); + var productAverageRating = new Rating(childLB.Average(c => c.LibraryBook.Book.Rating.OverallRating), childLB.Average(c => c.LibraryBook.Book.Rating.PerformanceRating), childLB.Average(c => c.LibraryBook.Book.Rating.StoryRating)); + + + Title = SeriesBook.Series.Name; + Series = SeriesBook.Series.Name; + Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; + MyRating = myAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + PurchaseDate = childLB.Min(c => c.LibraryBook.DateAdded).ToString("d"); + ProductRating = productAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + Authors = Book.AuthorNames(); + Narrators = Book.NarratorNames(); + Category = string.Join(" > ", Book.CategoriesNames()); + } + } + + // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable + // Used by Dinah.Core.DataBinding.SortableBindingList for all sorting + public override object GetMemberValue(string memberName) => _memberValues[memberName](); + + private Dictionary> _memberValues { get; set; } + + /// + /// Create getters for all member object values by name + /// + private Dictionary> CreateMemberValueDictionary() => new() + { + { nameof(Title), () => Book.SeriesSortable() }, + { nameof(Series), () => Book.SeriesSortable() }, + { nameof(Length), () => Children.Cast().Sum(c=>c.LibraryBook.Book.LengthInMinutes) }, + { nameof(MyRating), () => Children.Cast().Average(c=>c.LibraryBook.Book.UserDefinedItem.Rating.FirstScore()) }, + { nameof(PurchaseDate), () => Children.Cast().Min(c=>c.LibraryBook.DateAdded) }, + { nameof(ProductRating), () => Children.Cast().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(DisplayTags), () => string.Empty }, + { nameof(Liberate), () => Liberate.BookStatus }, + { nameof(DateAdded), () => DateAdded }, + }; + } +} diff --git a/Source/LibationWinForms/grid/SortableBindingList1.cs b/Source/LibationWinForms/grid/SortableBindingList1.cs new file mode 100644 index 00000000..7d12819f --- /dev/null +++ b/Source/LibationWinForms/grid/SortableBindingList1.cs @@ -0,0 +1,111 @@ +using Dinah.Core.DataBinding; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms +{ + internal class SortableBindingList1 : BindingList where T : class, IMemberComparable, IHierarchical + { + private bool isSorted; + private ListSortDirection listSortDirection; + private PropertyDescriptor propertyDescriptor; + + public SortableBindingList1() : base(new List()) { } + public SortableBindingList1(IEnumerable enumeration) : base(new List(enumeration)) { } + + protected MemberComparer Comparer { get; } = new(); + protected override bool SupportsSortingCore => true; + protected override bool SupportsSearchingCore => true; + protected override bool IsSortedCore => isSorted; + protected override PropertyDescriptor SortPropertyCore => propertyDescriptor; + protected override ListSortDirection SortDirectionCore => listSortDirection; + + protected override void ApplySortCore(PropertyDescriptor property, ListSortDirection direction) + { + Comparer.PropertyName = property.Name; + Comparer.Direction = direction; + + Sort(); + + propertyDescriptor = property; + listSortDirection = direction; + isSorted = true; + + OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + } + + protected void Sort() + { + List itemsList = (List)Items; + + //Array.Sort() and List.Sort() are unstable sorts. OrderBy is stable. + var sortedItems = itemsList.OrderBy((ge) => ge, Comparer).ToList(); + + var children = sortedItems.Where(i => i.Parent is not null).ToList(); + var parents = sortedItems.Where(i => i.Children is not null).ToList(); + + //Top Level items + var topLevelItems = sortedItems.Except(children); + + itemsList.Clear(); + itemsList.AddRange(topLevelItems); + + foreach (var p in parents) + { + var pIndex = itemsList.IndexOf(p); + foreach (var c in children.Where(c=> c.Parent == p)) + itemsList.Insert(++pIndex, c); + } + } + + protected override void OnListChanged(ListChangedEventArgs e) + { + if (isSorted && + ((e.ListChangedType == ListChangedType.ItemChanged && e.PropertyDescriptor == SortPropertyCore) || + e.ListChangedType == ListChangedType.ItemAdded)) + { + var item = Items[e.NewIndex]; + Sort(); + var newIndex = Items.IndexOf(item); + + base.OnListChanged(new ListChangedEventArgs(ListChangedType.ItemMoved, newIndex, e.NewIndex)); + } + else + base.OnListChanged(e); + } + + protected override void RemoveSortCore() + { + isSorted = false; + propertyDescriptor = base.SortPropertyCore; + listSortDirection = base.SortDirectionCore; + + OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + } + + protected override int FindCore(PropertyDescriptor property, object key) + { + int count = Count; + + System.Collections.IComparer valueComparer = null; + + for (int i = 0; i < count; ++i) + { + var element = this[i]; + var elemValue = element.GetMemberValue(property.Name); + valueComparer ??= element.GetMemberComparer(elemValue.GetType()); + + if (valueComparer.Compare(elemValue, key) == 0) + { + return i; + } + } + + return -1; + } + } +}