WinForms and Avalonia now share all GridEntry view models

This commit is contained in:
MBucari 2023-03-10 19:37:42 -07:00
parent e1cd8b8f94
commit fb9d062545
41 changed files with 1032 additions and 1704 deletions

View File

@ -1,7 +1,5 @@
using Avalonia.Controls;
using LibationAvalonia.ViewModels;
using System;
using System.Linq;
using LibationUiBase.GridView;
namespace LibationAvalonia.Controls
{
@ -11,7 +9,7 @@ namespace LibationAvalonia.Controls
{
//Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary.
var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
ele.IsThreeState = dataItem is SeriesEntry;
ele.IsThreeState = dataItem is ISeriesEntry;
return ele;
}
}

View File

@ -1,6 +1,6 @@
using Avalonia.Collections;
using Avalonia.Controls;
using LibationAvalonia.ViewModels;
using LibationUiBase.GridView;
using System;
using System.Reflection;
@ -30,7 +30,7 @@ namespace LibationAvalonia.Controls
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
{
if (sender is DataGridCell cell && cell.DataContext is GridEntry entry)
if (sender is DataGridCell cell && cell.DataContext is IGridEntry entry)
{
var args = new DataGridCellContextMenuStripNeededEventArgs
{
@ -63,7 +63,7 @@ namespace LibationAvalonia.Controls
public string CellClipboardContents => GetCellValue(Column, GridEntry);
public DataGridColumn Column { get; init; }
public GridEntry GridEntry { get; init; }
public IGridEntry GridEntry { get; init; }
public ContextMenu ContextMenu { get; init; }
public AvaloniaList<Control> ContextMenuItems
=> ContextMenu.Items as AvaloniaList<Control>;

View File

@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Interactivity;
using DataLayer;
using ReactiveUI;
@ -7,19 +8,10 @@ using System;
namespace LibationAvalonia.Controls
{
public class StarStringConverter : Avalonia.Data.Converters.IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
=> value is Rating rating ? rating.ToStarString() : string.Empty;
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
=> throw new NotImplementedException();
}
public class DataGridMyRatingColumn : DataGridBoundColumn
{
[Avalonia.Data.AssignBinding]
public Avalonia.Data.IBinding BackgroundBinding { get; set; }
[AssignBinding] public IBinding BackgroundBinding { get; set; }
[AssignBinding] public IBinding OpacityBinding { get; set; }
private static Rating DefaultRating => new Rating(0, 0, 0);
public DataGridMyRatingColumn()
{
@ -40,13 +32,11 @@ namespace LibationAvalonia.Controls
ToolTip.SetTip(myRatingElement, "Click to change ratings");
if (Binding != null)
{
myRatingElement.Bind(BindingTarget, Binding);
}
if (BackgroundBinding != null)
{
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
}
if (OpacityBinding != null)
myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding);
return myRatingElement;
}
@ -58,10 +48,11 @@ namespace LibationAvalonia.Controls
Name = "CellMyRatingEditor",
IsEditingMode = true
};
if (BackgroundBinding != null)
{
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
}
if (OpacityBinding != null)
myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding);
return myRatingElement;
}

View File

@ -0,0 +1,37 @@
using Avalonia.Media;
using Avalonia.Media.Imaging;
using DataLayer;
using LibationUiBase.GridView;
using System;
namespace LibationAvalonia.ViewModels
{
public class AvaloniaEntryStatus : EntryStatus, IEntryStatus, IComparable
{
private static Bitmap _defaultImage;
public override IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent;
private AvaloniaEntryStatus(LibraryBook libraryBook) : base(libraryBook) { }
public static EntryStatus Create(LibraryBook libraryBook) => new AvaloniaEntryStatus(libraryBook);
protected override Bitmap LoadImage(byte[] picture)
{
try
{
using var ms = new System.IO.MemoryStream(picture);
return new Bitmap(ms);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book);
return _defaultImage ??= new Bitmap(App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg"));
}
}
protected override Bitmap GetResourceImage(string rescName)
{
using var stream = App.OpenAsset(rescName + ".png");
return new Bitmap(stream);
}
}
}

View File

@ -1,9 +0,0 @@
namespace LibationAvalonia.ViewModels
{
public class BookTags
{
private string _tags;
public string Tags { get => _tags; init { _tags = value; HasTags = !string.IsNullOrEmpty(_tags); } }
public bool HasTags { get; init; }
}
}

View File

@ -1,238 +0,0 @@
using ApplicationServices;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationFileManager;
using LibationUiBase;
using ReactiveUI;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels
{
public enum RemoveStatus
{
NotRemoved,
Removed,
SomeRemoved
}
/// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry : ViewModelBase
{
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
[Browsable(false)] public float SeriesIndex { get; protected set; }
[Browsable(false)] public string LongDescription { get; protected set; }
[Browsable(false)] public abstract DateTime DateAdded { get; }
[Browsable(false)] public int ListIndex { get; set; }
[Browsable(false)] public Book Book => LibraryBook.Book;
#region Model properties exposed to the view
private Avalonia.Media.Imaging.Bitmap _cover;
private string _purchasedate;
private string _series;
private string _title;
private string _length;
private string _authors;
private string _narrators;
private string _category;
private string _misc;
private LastDownloadStatus _lastDownload;
private string _description;
private Rating _productrating;
protected Rating _myRating;
public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set => this.RaiseAndSetIfChanged(ref _cover, value); }
public string PurchaseDate { get => _purchasedate; protected set => this.RaiseAndSetIfChanged(ref _purchasedate, value); }
public string Series { get => _series; protected set => this.RaiseAndSetIfChanged(ref _series, value); }
public string Title { get => _title; protected set => this.RaiseAndSetIfChanged(ref _title, value); }
public string Length { get => _length; protected set => this.RaiseAndSetIfChanged(ref _length, value); }
public string Authors { get => _authors; protected set => this.RaiseAndSetIfChanged(ref _authors, value); }
public string Narrators { get => _narrators; protected set => this.RaiseAndSetIfChanged(ref _narrators, value); }
public string Category { get => _category; protected set => this.RaiseAndSetIfChanged(ref _category, value); }
public LastDownloadStatus LastDownload { get => _lastDownload; protected set => this.RaiseAndSetIfChanged(ref _lastDownload, value); }
public string Misc { get => _misc; protected set => this.RaiseAndSetIfChanged(ref _misc, value); }
public string Description { get => _description; protected set => this.RaiseAndSetIfChanged(ref _description, value); }
public Rating ProductRating { get => _productrating; protected set => this.RaiseAndSetIfChanged(ref _productrating, value); }
public Rating MyRating
{
get => _myRating;
set
{
if (_myRating != value
&& value.OverallRating != 0
&& updateReviewTask?.IsCompleted is not false)
{
updateReviewTask = UpdateRating(value);
}
}
}
protected bool? _remove = false;
public abstract bool? Remove { get; set; }
public abstract LiberateButtonStatus Liberate { get; }
public abstract BookTags BookTags { get; }
public abstract bool IsEpisode { get; }
public abstract bool IsBook { get; }
public IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent;
#endregion
#region User rating
private Task updateReviewTask;
private async Task UpdateRating(Rating rating)
{
var api = await LibraryBook.GetApiAsync();
if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating))
{
_myRating = rating;
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating);
}
this.RaisePropertyChanged(nameof(MyRating));
}
#endregion
#region Sorting
public GridEntry() => _memberValues = CreateMemberValueDictionary();
// These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
// Used by GridEntryBindingList for all sorting
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
protected abstract Dictionary<string, Func<object>> CreateMemberValueDictionary();
private Dictionary<string, Func<object>> _memberValues { get; set; }
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new()
{
{ typeof(RemoveStatus), new ObjectComparer<RemoveStatus>() },
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
};
#endregion
#region Cover Art
protected void LoadCover()
{
// Get cover art. If it's default, subscribe to PictureCached
(bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
if (isDefault)
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
_cover = loadImage(picture);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
// state validation
if (e is null ||
e.Definition.PictureId is null ||
Book?.PictureId is null ||
e.Picture is null ||
e.Picture.Length == 0)
return;
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
Cover = loadImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
private Bitmap loadImage(byte[] picture)
{
try
{
using var ms = new System.IO.MemoryStream(picture);
return new Bitmap(ms);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book);
return DefaultImage;
}
}
private static Bitmap _defaultImage;
private static Bitmap DefaultImage => _defaultImage ??= new Bitmap(App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg"));
#endregion
#region Static library display functions
/// <summary>This information should not change during <see cref="GridEntry"/> lifetime, so call only once.</summary>
protected static string GetDescriptionDisplay(Book book)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
protected static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
protected static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
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;
}
}
}

View File

@ -1,129 +0,0 @@
using Avalonia.Media.Imaging;
using DataLayer;
using ReactiveUI;
using System;
using System.Collections.Generic;
namespace LibationAvalonia.ViewModels
{
public class LiberateButtonStatus : ViewModelBase, IComparable
{
public LiberateButtonStatus(bool isSeries, bool isAbsent)
{
IsSeries = isSeries;
IsAbsent = isAbsent;
}
public LiberatedStatus BookStatus { get; set; }
public LiberatedStatus? PdfStatus { get; set; }
private bool _expanded;
public bool Expanded
{
get => _expanded;
set
{
this.RaiseAndSetIfChanged(ref _expanded, value);
this.RaisePropertyChanged(nameof(Image));
this.RaisePropertyChanged(nameof(ToolTip));
}
}
private bool IsAbsent { get; }
public bool IsSeries { get; }
public bool IsUnavailable => !IsSeries & IsAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
public Bitmap Image => GetLiberateIcon();
public string ToolTip => GetTooltip();
static Dictionary<string, Bitmap> iconCache = new();
/// <summary> Defines the Liberate column's sorting behavior </summary>
public int CompareTo(object obj)
{
if (obj is not LiberateButtonStatus second) return -1;
if (IsSeries && !second.IsSeries) return -1;
else if (!IsSeries && second.IsSeries) return 1;
else if (IsSeries && second.IsSeries) return 0;
else if (IsUnavailable && !second.IsUnavailable) return 1;
else if (!IsUnavailable && second.IsUnavailable) return -1;
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
else return BookStatus.CompareTo(second.BookStatus);
}
private Bitmap GetLiberateIcon()
{
if (IsSeries)
return Expanded ? GetFromResources("minus") : GetFromResources("plus");
if (BookStatus == LiberatedStatus.Error)
return GetFromResources("error");
string image_lib = BookStatus switch
{
LiberatedStatus.Liberated => "green",
LiberatedStatus.PartialDownload => "yellow",
LiberatedStatus.NotLiberated => "red",
_ => throw new Exception("Unexpected liberation state")
};
string image_pdf = PdfStatus switch
{
LiberatedStatus.Liberated => "_pdf_yes",
LiberatedStatus.NotLiberated => "_pdf_no",
LiberatedStatus.Error => "_pdf_no",
null => "",
_ => throw new Exception("Unexpected PDF state")
};
return GetFromResources($"liberate_{image_lib}{image_pdf}");
}
private string GetTooltip()
{
if (IsSeries)
return Expanded ? "Click to Collpase" : "Click to Expand";
if (IsUnavailable)
return "This book cannot be downloaded\nbecause it wasn't found during\nthe most recent library scan";
if (BookStatus == LiberatedStatus.Error)
return "Book downloaded ERROR";
string libState = BookStatus switch
{
LiberatedStatus.Liberated => "Liberated",
LiberatedStatus.PartialDownload => "File has been at least\r\npartially downloaded",
LiberatedStatus.NotLiberated => "Book NOT downloaded",
_ => throw new Exception("Unexpected liberation state")
};
string pdfState = PdfStatus switch
{
LiberatedStatus.Liberated => "\r\nPDF downloaded",
LiberatedStatus.NotLiberated => "\r\nPDF NOT downloaded",
LiberatedStatus.Error => "\r\nPDF downloaded ERROR",
null => "",
_ => throw new Exception("Unexpected PDF state")
};
var mouseoverText = libState + pdfState;
if (BookStatus == LiberatedStatus.NotLiberated ||
BookStatus == LiberatedStatus.PartialDownload ||
PdfStatus == LiberatedStatus.NotLiberated)
mouseoverText += "\r\nClick to complete";
return mouseoverText;
}
private static Bitmap GetFromResources(string rescName)
{
if (iconCache.ContainsKey(rescName)) return iconCache[rescName];
iconCache[rescName] = new Bitmap(App.OpenAsset(rescName + ".png"));
return iconCache[rescName];
}
}
}

View File

@ -1,170 +0,0 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationAvalonia.ViewModels
{
/// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
public class LibraryBookEntry : GridEntry
{
[Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
[Browsable(false)] public SeriesEntry Parent { get; init; }
#region Model properties exposed to the view
private DateTime lastStatusUpdate = default;
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public override bool? Remove
{
get => _remove;
set
{
_remove = value ?? false;
Parent?.ChildRemoveUpdate();
this.RaisePropertyChanged(nameof(Remove));
}
}
public override LiberateButtonStatus Liberate
{
get
{
//Cache these statuses for faster sorting.
if ((DateTime.Now - lastStatusUpdate).TotalSeconds > 2)
{
_bookStatus = LibraryCommands.Liberated_Status(LibraryBook.Book);
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
lastStatusUpdate = DateTime.Now;
}
return new LiberateButtonStatus(isSeries: false, LibraryBook.AbsentFromLastScan) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
}
}
public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) };
public override bool IsEpisode => Parent is not null;
public override bool IsBook => Parent is null;
#endregion
public LibraryBookEntry(LibraryBook libraryBook)
{
setLibraryBook(libraryBook);
LoadCover();
}
public void UpdateLibraryBook(LibraryBook libraryBook)
{
if (AudibleProductId != libraryBook.Book.AudibleProductId)
throw new Exception("Invalid grid entry update. IDs must match");
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
setLibraryBook(libraryBook);
}
private void setLibraryBook(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
Title = Book.Title;
Series = Book.SeriesNames();
Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min";
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
//the reference doesn't change. Clone the rating so that it updates within Avalonia properly.
_myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating);
PurchaseDate = libraryBook.DateAdded.ToString("d");
ProductRating = Book.Rating ?? new Rating(0, 0, 0);
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LastDownload = new(Book.UserDefinedItem);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
this.RaisePropertyChanged(nameof(MyRating));
this.RaisePropertyChanged(nameof(Liberate));
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
#region detect changes to the model, update the view.
/// <summary>
/// This event handler receives notifications from the model that it has changed.
/// Notify the view that it's changed.
/// </summary>
private void UserDefinedItem_ItemChanged(object sender, string itemName)
{
var udi = sender as UserDefinedItem;
if (udi.Book.AudibleProductId != Book.AudibleProductId)
return;
// UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view.
// - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs
// - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view.
switch (itemName)
{
case nameof(udi.Tags):
Book.UserDefinedItem.Tags = udi.Tags;
this.RaisePropertyChanged(nameof(BookTags));
break;
case nameof(udi.BookStatus):
Book.UserDefinedItem.BookStatus = udi.BookStatus;
_bookStatus = udi.BookStatus;
this.RaisePropertyChanged(nameof(Liberate));
break;
case nameof(udi.PdfStatus):
Book.UserDefinedItem.SetPdfStatus(udi.PdfStatus);
_pdfStatus = udi.PdfStatus;
this.RaisePropertyChanged(nameof(Liberate));
break;
case nameof(udi.LastDownloaded):
LastDownload = new(udi);
this.RaisePropertyChanged(nameof(LastDownload));
break;
}
}
#endregion
#region Data Sorting
/// <summary>Create getters for all member object values by name </summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Book.LengthInMinutes },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
{ nameof(PurchaseDate), () => LibraryBook.DateAdded },
{ nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
#endregion
~LibraryBookEntry()
{
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
}
}
}

View File

@ -1,15 +1,16 @@
using ApplicationServices;
using AudibleUtilities;
using Avalonia.Collections;
using Avalonia.Threading;
using DataLayer;
using LibationAvalonia.Dialogs.Login;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using ReactiveUI;
using Avalonia.Threading;
using ApplicationServices;
using AudibleUtilities;
using LibationAvalonia.Dialogs.Login;
using Avalonia.Collections;
namespace LibationAvalonia.ViewModels
{
@ -20,9 +21,9 @@ namespace LibationAvalonia.ViewModels
public event EventHandler<int> RemovableCountChanged;
/// <summary>Backing list of all grid entries</summary>
private readonly AvaloniaList<GridEntry> SOURCE = new();
private readonly AvaloniaList<IGridEntry> SOURCE = new();
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
private List<GridEntry> FilteredInGridEntries;
private List<IGridEntry> FilteredInGridEntries;
public string FilterString { get; private set; }
public DataGridCollectionView GridEntries { get; private set; }
@ -31,11 +32,11 @@ namespace LibationAvalonia.ViewModels
public List<LibraryBook> GetVisibleBookEntries()
=> GridEntries
.OfType<LibraryBookEntry>()
.OfType<ILibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList();
private IEnumerable<LibraryBookEntry> GetAllBookEntries()
private IEnumerable<ILibraryBookEntry> GetAllBookEntries()
=> SOURCE
.BookEntries();
@ -92,11 +93,10 @@ namespace LibationAvalonia.ViewModels
var geList = dbBooks
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry(b))
.Cast<GridEntry>()
.ToList();
.Select(b => new LibraryBookEntry<AvaloniaEntryStatus>(b))
.ToList<IGridEntry>();
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()).ToList();
var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
@ -106,7 +106,7 @@ namespace LibationAvalonia.ViewModels
if (!seriesEpisodes.Any()) continue;
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
var seriesEntry = new SeriesEntry<AvaloniaEntryStatus>(parent, seriesEpisodes);
seriesEntry.Liberate.Expanded = false;
geList.Add(seriesEntry);
@ -116,9 +116,10 @@ namespace LibationAvalonia.ViewModels
//Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = QueryResults(geList, FilterString);
SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded));
VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
GridEntries.CollectionChanged += (_, _)
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<ILibraryBookEntry>().Count());
VisibleCountChanged?.Invoke(this, GridEntries.OfType<ILibraryBookEntry>().Count());
}
/// <summary>
@ -129,7 +130,7 @@ namespace LibationAvalonia.ViewModels
#region Add new or update existing grid entries
//Add absent entries to grid, or update existing entry
var allEntries = SOURCE.BookEntries();
var allEntries = SOURCE.BookEntries().ToList();
var seriesEntries = SOURCE.SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToList();
@ -163,7 +164,7 @@ namespace LibationAvalonia.ViewModels
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Parent is not null))
foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode))
removed.Parent.RemoveChild(removed);
//Remove series that have no children
@ -174,11 +175,12 @@ namespace LibationAvalonia.ViewModels
#endregion
await Filter(FilterString);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
private void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks, IEnumerable<SeriesEntry> removedSeries)
private void RemoveBooks(IEnumerable<ILibraryBookEntry> removedBooks, IEnumerable<ISeriesEntry> removedSeries)
{
foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries).Where(b => b is not null).ToList())
foreach (var removed in removedBooks.Cast<IGridEntry>().Concat(removedSeries).Where(b => b is not null).ToList())
{
if (GridEntries.PassesFilter(removed))
GridEntries.Remove(removed);
@ -191,21 +193,21 @@ namespace LibationAvalonia.ViewModels
}
}
private void UpsertBook(LibraryBook book, LibraryBookEntry existingBookEntry)
private void UpsertBook(LibraryBook book, ILibraryBookEntry existingBookEntry)
{
if (existingBookEntry is null)
// Add the new product to top
SOURCE.Insert(0, new LibraryBookEntry(book));
SOURCE.Insert(0, new LibraryBookEntry<AvaloniaEntryStatus>(book));
else
// update existing
existingBookEntry.UpdateLibraryBook(book);
}
private void UpsertEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
private void UpsertEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List<ISeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (existingEpisodeEntry is null)
{
LibraryBookEntry episodeEntry;
ILibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
@ -223,7 +225,7 @@ namespace LibationAvalonia.ViewModels
return;
}
seriesEntry = new SeriesEntry(seriesBook, new[] { episodeBook });
seriesEntry = new SeriesEntry<AvaloniaEntryStatus>(seriesBook, episodeBook);
seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0];
@ -233,7 +235,7 @@ namespace LibationAvalonia.ViewModels
else
{
//Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new(episodeBook) { Parent = seriesEntry };
episodeEntry = new LibraryBookEntry<AvaloniaEntryStatus>(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateLibraryBook(seriesBook);
@ -255,7 +257,7 @@ namespace LibationAvalonia.ViewModels
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
}
public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry)
public async Task ToggleSeriesExpanded(ISeriesEntry seriesEntry)
{
seriesEntry.Liberate.Expanded = !seriesEntry.Liberate.Expanded;
@ -280,8 +282,8 @@ namespace LibationAvalonia.ViewModels
private bool CollectionFilter(object item)
{
if (item is LibraryBookEntry lbe
&& lbe.IsEpisode
if (item is ILibraryBookEntry lbe
&& lbe.Liberate.IsEpisode
&& lbe.Parent?.Liberate?.Expanded != true)
return false;
@ -290,13 +292,13 @@ namespace LibationAvalonia.ViewModels
return FilteredInGridEntries.Contains(item);
}
private static List<GridEntry> QueryResults(IEnumerable<GridEntry> entries, string searchString)
private static List<IGridEntry> QueryResults(IEnumerable<IGridEntry> entries, string searchString)
{
if (string.IsNullOrEmpty(searchString)) return null;
var searchResultSet = SearchEngineCommands.Search(searchString);
var booksFilteredIn = entries.BookEntries().Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe);
var booksFilteredIn = entries.BookEntries().Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe);
//Find all series containing children that match the search criteria
var seriesFilteredIn = entries.SeriesEntries().Where(s => s.Children.Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any());
@ -411,7 +413,7 @@ namespace LibationAvalonia.ViewModels
private void GridEntry_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(GridEntry.Remove) && sender is LibraryBookEntry lbEntry)
if (e.PropertyName == nameof(IGridEntry.Remove) && sender is ILibraryBookEntry)
{
int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true);
RemovableCountChanged?.Invoke(this, removeCount);

View File

@ -1,5 +1,5 @@
using Avalonia.Controls;
using System;
using LibationUiBase.GridView;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
@ -13,7 +13,7 @@ namespace LibationAvalonia.ViewModels
/// sorted by series index, ascending. Stable sorting is achieved by comparing the GridEntry.ListIndex
/// properties when 2 items compare equal.
/// </summary>
internal class RowComparer : IComparer, IComparer<GridEntry>, IComparer<object>
internal class RowComparer : IComparer, IComparer<IGridEntry>, IComparer<object>
{
private static readonly PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance);
private static readonly PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance);
@ -33,22 +33,22 @@ namespace LibationAvalonia.ViewModels
if (x is not null && y is null) return 1;
if (x is null && y is null) return 0;
var geA = (GridEntry)x;
var geB = (GridEntry)y;
var geA = (IGridEntry)x;
var geB = (IGridEntry)y;
var sortDirection = GetSortOrder();
SeriesEntry parentA = null;
SeriesEntry parentB = null;
ISeriesEntry parentA = null;
ISeriesEntry parentB = null;
if (geA is LibraryBookEntry lbA && lbA.Parent is SeriesEntry seA)
if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA)
parentA = seA;
if (geB is LibraryBookEntry lbB && lbB.Parent is SeriesEntry seB)
if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry seB)
parentB = seB;
//both a and b are top-level grid entries
if (parentA is null && parentB is null)
return InternalCompare(geA, geB, sortDirection);
return InternalCompare(geA, geB);
//a is top-level, b is a child
if (parentA is null && parentB is not null)
@ -57,7 +57,7 @@ namespace LibationAvalonia.ViewModels
if (parentB == geA)
return sortDirection is ListSortDirection.Ascending ? -1 : 1;
else
return InternalCompare(geA, parentB, sortDirection);
return InternalCompare(geA, parentB);
}
//a is a child, b is a top-level
@ -67,7 +67,7 @@ namespace LibationAvalonia.ViewModels
if (parentA == geB)
return sortDirection is ListSortDirection.Ascending ? 1 : -1;
else
return InternalCompare(parentA, geB, sortDirection);
return InternalCompare(parentA, geB);
}
//both are children of the same series, always present in order of series index, ascending
@ -75,29 +75,22 @@ namespace LibationAvalonia.ViewModels
return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (sortDirection is ListSortDirection.Ascending ? 1 : -1);
//a and b are children of different series.
return InternalCompare(parentA, parentB, sortDirection);
return InternalCompare(parentA, parentB);
}
//Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection
private ListSortDirection? GetSortOrder()
=> CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?;
private int InternalCompare(GridEntry x, GridEntry y, ListSortDirection? sortDirection)
private int InternalCompare(IGridEntry x, IGridEntry y)
{
var val1 = x.GetMemberValue(PropertyName);
var val2 = y.GetMemberValue(PropertyName);
var compareResult = x.GetMemberComparer(val1.GetType()).Compare(val1, val2);
//If items compare equal, compare them by their positions in the the list.
//This is how you achieve a stable sort.
if (compareResult == 0)
return x.ListIndex.CompareTo(y.ListIndex) * (sortDirection is ListSortDirection.Ascending ? 1 : -1);
else
return compareResult;
return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); ;
}
public int Compare(GridEntry x, GridEntry y)
public int Compare(IGridEntry x, IGridEntry y)
{
return Compare((object)x, y);
}

View File

@ -1,135 +0,0 @@
using DataLayer;
using Dinah.Core;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationAvalonia.ViewModels
{
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry : GridEntry
{
[Browsable(false)] public List<LibraryBookEntry> Children { get; }
[Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded);
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove == true);
_remove = removeCount == 0 ? false : (removeCount == Children.Count ? true : null);
this.RaisePropertyChanged(nameof(Remove));
}
#region Model properties exposed to the view
public override bool? Remove
{
get => _remove;
set
{
_remove = value ?? false;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
this.RaisePropertyChanged(nameof(Remove));
}
}
public override LiberateButtonStatus Liberate { get; }
public override BookTags BookTags { get; } = new();
public override bool IsEpisode => false;
public override bool IsBook => false;
#endregion
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children)
{
Liberate = new LiberateButtonStatus(isSeries: true, isAbsent: false);
SeriesIndex = -1;
Children = children
.Select(c => new LibraryBookEntry(c) { Parent = this })
.OrderBy(c => c.SeriesIndex)
.ToList();
setLibraryBook(parent);
LoadCover();
}
public void RemoveChild(LibraryBookEntry lbe)
{
Children.Remove(lbe);
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
}
public void UpdateLibraryBook(LibraryBook libraryBook)
{
if (AudibleProductId != libraryBook.Book.AudibleProductId)
throw new Exception("Invalid grid entry update. IDs must match");
setLibraryBook(libraryBook);
}
private void setLibraryBook(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
Title = Book.Title;
Series = Book.SeriesNames();
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
//the reference doesn't change. Clone the rating so that it updates within Avalonia properly.
_myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating);
ProductRating = Book.Rating ?? new Rating(0, 0, 0);
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(LibraryBook);
LastDownload = new();
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
this.RaisePropertyChanged(nameof(MyRating));
}
#region Data Sorting
/// <summary>Create getters for all member object values by name</summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
{ nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) },
{ nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
#endregion
}
}

View File

@ -33,10 +33,6 @@
</Style>
</DataGrid.Styles>
<DataGrid.Resources>
<controls:StarStringConverter x:Key="starStringConverter" />
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTemplateColumn
@ -62,10 +58,10 @@
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel ToolTip.Tip="{Binding Liberate.ToolTip}">
<Button Opacity="{Binding Opacity}" Padding="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Click="LiberateButton_Click" IsVisible="{Binding !Liberate.IsUnavailable}">
<Image Source="{Binding Liberate.Image}" Stretch="None" />
<Button Opacity="{Binding Liberate.Opacity}" Padding="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Click="LiberateButton_Click" IsVisible="{Binding !Liberate.IsUnavailable}">
<Image Source="{Binding Liberate.ButtonImage}" Stretch="None" />
</Button>
<Image Source="{Binding Liberate.Image}" Stretch="None" IsVisible="{Binding Liberate.IsUnavailable}"/>
<Image Source="{Binding Liberate.ButtonImage}" Stretch="None" IsVisible="{Binding Liberate.IsUnavailable}"/>
<Panel Background="{StaticResource DisabledGrayBrush}" IsVisible="{Binding Liberate.IsUnavailable}" />
</Panel>
</DataTemplate>
@ -75,7 +71,7 @@
<DataGridTemplateColumn CanUserSort="False" Width="80" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Image Opacity="{Binding Opacity}" Tapped="Cover_Click" Height="80" Source="{Binding Cover}" ToolTip.Tip="Click to see full size" />
<Image Opacity="{Binding Liberate.Opacity}" Tapped="Cover_Click" Height="80" Source="{Binding Cover}" ToolTip.Tip="Click to see full size" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@ -83,7 +79,7 @@
<controls:DataGridTemplateColumnExt MinWidth="150" Width="2*" Header="Title" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Title}" />
</Panel>
</DataTemplate>
@ -93,7 +89,7 @@
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Authors" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Authors}" />
</Panel>
</DataTemplate>
@ -103,7 +99,7 @@
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Narrators" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Narrators}" />
</Panel>
</DataTemplate>
@ -113,7 +109,7 @@
<controls:DataGridTemplateColumnExt Width="90" Header="Length" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Length}" />
</Panel>
</DataTemplate>
@ -123,7 +119,7 @@
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Series" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Series}" />
</Panel>
</DataTemplate>
@ -133,7 +129,7 @@
<controls:DataGridTemplateColumnExt MinWidth="100" Width="1*" Header="Description" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding LongDescription}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
<TextBlock Text="{Binding Description}" FontSize="11" VerticalAlignment="Top" />
</Panel>
</DataTemplate>
@ -143,7 +139,7 @@
<controls:DataGridTemplateColumnExt Width="100" Header="Category" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Category}" />
</Panel>
</DataTemplate>
@ -155,14 +151,15 @@
IsReadOnly="true"
Width="115"
SortMemberPath="ProductRating" CanUserSort="True"
BackgroundBinding="{Binding BackgroundBrush}"
ClipboardContentBinding="{Binding ProductRating, Converter={StaticResource starStringConverter}}"
OpacityBinding="{Binding Liberate.Opacity}"
BackgroundBinding="{Binding Liberate.BackgroundBrush}"
ClipboardContentBinding="{Binding ProductRating}"
Binding="{Binding ProductRating}" />
<controls:DataGridTemplateColumnExt Width="90" Header="Purchase&#xA;Date" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding PurchaseDate}" />
</Panel>
</DataTemplate>
@ -174,14 +171,15 @@
IsReadOnly="false"
Width="115"
SortMemberPath="MyRating" CanUserSort="True"
BackgroundBinding="{Binding BackgroundBrush}"
ClipboardContentBinding="{Binding MyRating, Converter={StaticResource starStringConverter}}"
OpacityBinding="{Binding Liberate.Opacity}"
BackgroundBinding="{Binding Liberate.BackgroundBrush}"
ClipboardContentBinding="{Binding MyRating}"
Binding="{Binding MyRating, Mode=TwoWay}" />
<controls:DataGridTemplateColumnExt Width="135" Header="Misc" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Misc}" TextWrapping="WrapWithOverflow" FontSize="10" />
</Panel>
</DataTemplate>
@ -191,20 +189,20 @@
<controls:DataGridTemplateColumnExt Width="102" Header="Last&#xA;Download" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" ToolTip.Tip="{Binding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}" ToolTip.Tip="{Binding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
<TextBlock Text="{Binding LastDownload}" TextWrapping="WrapWithOverflow" FontSize="10" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags.Tags}">
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button IsVisible="{Binding !IsSeries}" Width="100" Height="80" Click="OnTagsButtonClick" ToolTip.Tip="Click to edit tags" >
<Panel>
<Image IsVisible="{Binding !BookTags.HasTags}" Stretch="None" Source="/Assets/edit_25x25.png" />
<TextBlock IsVisible="{Binding BookTags.HasTags}" FontSize="12" TextWrapping="WrapWithOverflow" Text="{Binding BookTags.Tags}"/>
<Button IsVisible="{Binding !Liberate.IsSeries}" Width="100" Height="80" Click="OnTagsButtonClick" ToolTip.Tip="Click to edit tags" >
<Panel Opacity="{Binding Liberate.Opacity}">
<Image IsVisible="{Binding BookTags, Converter={x:Static StringConverters.IsNullOrEmpty}}" Stretch="None" Source="/Assets/edit_25x25.png" />
<TextBlock IsVisible="{Binding BookTags, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" FontSize="12" TextWrapping="WrapWithOverflow" HorizontalAlignment="Center" VerticalAlignment="Center" Text="{Binding BookTags}"/>
</Panel>
</Button>
</DataTemplate>

View File

@ -1,7 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
@ -13,6 +9,11 @@ using LibationAvalonia.Controls;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using LibationUiBase.GridView;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Views
{
@ -200,7 +201,7 @@ namespace LibationAvalonia.Views
{
var itemName = column.SortMemberPath;
if (itemName == nameof(GridEntry.Remove))
if (itemName == nameof(IGridEntry.Remove))
continue;
menuItems.Add
@ -291,7 +292,7 @@ namespace LibationAvalonia.Views
{
var button = args.Source as Button;
if (button.DataContext is SeriesEntry sEntry)
if (button.DataContext is ISeriesEntry sEntry)
{
await _viewModel.ToggleSeriesExpanded(sEntry);
@ -299,7 +300,7 @@ namespace LibationAvalonia.Views
//to the topright cell. Reset focus onto the clicked button's cell.
(sender as Button).Parent?.Focus();
}
else if (button.DataContext is LibraryBookEntry lbEntry)
else if (button.DataContext is ILibraryBookEntry lbEntry)
{
LiberateClicked?.Invoke(this, lbEntry.LibraryBook);
}
@ -313,13 +314,13 @@ namespace LibationAvalonia.Views
public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args)
{
if (sender is Control panel && panel.DataContext is LibraryBookEntry lbe && lbe.LastDownload.IsValid)
if (sender is Control panel && panel.DataContext is ILibraryBookEntry lbe && lbe.LastDownload.IsValid)
lbe.LastDownload.OpenReleaseUrl();
}
public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args)
{
if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry)
if (sender is not Image tblock || tblock.DataContext is not IGridEntry gEntry)
return;
if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible)
@ -358,7 +359,7 @@ namespace LibationAvalonia.Views
public void Description_Click(object sender, Avalonia.Input.TappedEventArgs args)
{
if (sender is Control tblock && tblock.DataContext is GridEntry gEntry)
if (sender is Control tblock && tblock.DataContext is IGridEntry gEntry)
{
var pt = tblock.PointToScreen(tblock.Bounds.TopRight);
var displayWindow = new DescriptionDisplayDialog
@ -387,7 +388,7 @@ namespace LibationAvalonia.Views
{
var button = args.Source as Button;
if (button.DataContext is LibraryBookEntry lbEntry && VisualRoot is Window window)
if (button.DataContext is ILibraryBookEntry lbEntry && VisualRoot is Window window)
{
if (bookDetailsForm is null || !bookDetailsForm.IsVisible)
{

View File

@ -0,0 +1,166 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Threading;
using System;
using System.Collections.Generic;
using System.ComponentModel;
namespace LibationUiBase.GridView
{
public interface IEntryStatus
{
static abstract EntryStatus Create(LibraryBook libraryBook);
}
//This Class holds all book entry status info to help the grid properly render entries.
//The reason this info is in here instead of GridEntry is because all of this info is needed
//for the "Liberate" column's display and sorting functions.
public abstract class EntryStatus : SynchronizeInvoker, IComparable, INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book);
public LiberatedStatus BookStatus
{
get
{
if (IsSeries) return default;
if ((DateTime.Now - lastBookUpdate).TotalSeconds > 2)
{
//Cache the BookStatus so AudibleFileStorage.AaxcExists isn't
//called multiple times per book while sorting the solumn.
bookStatus = LibraryCommands.Liberated_Status(Book);
lastBookUpdate = DateTime.Now;
}
return bookStatus;
}
}
public bool Expanded { get; set; }
public bool IsSeries { get; }
public bool IsEpisode { get; }
public bool IsBook => !IsSeries && !IsEpisode;
public bool IsUnavailable => !IsSeries & isAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
public double Opacity => !IsSeries && Book.UserDefinedItem.Tags.ContainsInsensitive("hidden") ? 0.4 : 1;
public abstract object BackgroundBrush { get; }
public object ButtonImage => GetLiberateIcon();
public string ToolTip => GetTooltip();
protected Book Book { get; }
private DateTime lastBookUpdate;
private LiberatedStatus bookStatus;
private readonly bool isAbsent;
private static readonly Dictionary<string, object> iconCache = new();
protected EntryStatus(LibraryBook libraryBook)
{
Book = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)).Book;
isAbsent = libraryBook.AbsentFromLastScan is true;
IsEpisode = Book.ContentType is ContentType.Episode;
IsSeries = Book.ContentType is ContentType.Parent;
}
internal protected abstract object LoadImage(byte[] picture);
protected abstract object GetResourceImage(string rescName);
public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args));
public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName));
/// <summary>Refresh BookStatus (so partial download files are checked again in the filesystem) and raise PropertyChanged for property names.</summary>
public void Invalidate(params string[] properties)
{
lastBookUpdate = default;
foreach (var property in properties)
RaisePropertyChanged(property);
}
/// <summary> Defines the Liberate column's sorting behavior </summary>
public int CompareTo(object obj)
{
if (obj is not EntryStatus second) return -1;
if (IsSeries && !second.IsSeries) return -1;
else if (!IsSeries && second.IsSeries) return 1;
else if (IsSeries && second.IsSeries) return 0;
else if (IsUnavailable && !second.IsUnavailable) return 1;
else if (!IsUnavailable && second.IsUnavailable) return -1;
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
else return BookStatus.CompareTo(second.BookStatus);
}
private object GetLiberateIcon()
{
if (IsSeries)
return Expanded ? GetAndCacheResource("minus") : GetAndCacheResource("plus");
if (BookStatus == LiberatedStatus.Error)
return GetAndCacheResource("error");
string image_lib = BookStatus switch
{
LiberatedStatus.Liberated => "green",
LiberatedStatus.PartialDownload => "yellow",
LiberatedStatus.NotLiberated => "red",
_ => throw new Exception("Unexpected liberation state")
};
string image_pdf = PdfStatus switch
{
LiberatedStatus.Liberated => "_pdf_yes",
LiberatedStatus.NotLiberated => "_pdf_no",
LiberatedStatus.Error => "_pdf_no",
null => "",
_ => throw new Exception("Unexpected PDF state")
};
return GetAndCacheResource($"liberate_{image_lib}{image_pdf}");
}
private string GetTooltip()
{
if (IsSeries)
return Expanded ? "Click to Collpase" : "Click to Expand";
if (IsUnavailable)
return "This book cannot be downloaded\nbecause it wasn't found during\nthe most recent library scan";
if (BookStatus == LiberatedStatus.Error)
return "Book downloaded ERROR";
string libState = BookStatus switch
{
LiberatedStatus.Liberated => "Liberated",
LiberatedStatus.PartialDownload => "File has been at least\r\npartially downloaded",
LiberatedStatus.NotLiberated => "Book NOT downloaded",
_ => throw new Exception("Unexpected liberation state")
};
string pdfState = PdfStatus switch
{
LiberatedStatus.Liberated => "\r\nPDF downloaded",
LiberatedStatus.NotLiberated => "\r\nPDF NOT downloaded",
LiberatedStatus.Error => "\r\nPDF downloaded ERROR",
null => "",
_ => throw new Exception("Unexpected PDF state")
};
var mouseoverText = libState + pdfState;
if (BookStatus == LiberatedStatus.NotLiberated ||
BookStatus == LiberatedStatus.PartialDownload ||
PdfStatus == LiberatedStatus.NotLiberated)
mouseoverText += "\r\nClick to complete";
return mouseoverText;
}
private object GetAndCacheResource(string rescName)
{
if (!iconCache.ContainsKey(rescName))
iconCache[rescName] = GetResourceImage(rescName);
return iconCache[rescName];
}
}
}

View File

@ -0,0 +1,324 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Threading;
using FileLiberator;
using LibationFileManager;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace LibationUiBase.GridView
{
public enum RemoveStatus
{
NotRemoved,
Removed,
SomeRemoved
}
/// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry<TStatus> : SynchronizeInvoker, IGridEntry where TStatus : IEntryStatus
{
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
[Browsable(false)] public float SeriesIndex { get; protected set; }
[Browsable(false)] public string LongDescription { get; protected set; }
[Browsable(false)] public abstract DateTime DateAdded { get; }
[Browsable(false)] public Book Book => LibraryBook.Book;
#region Model properties exposed to the view
protected bool? remove = false;
private string _purchasedate;
private string _length;
private LastDownloadStatus _lastDownload;
private object _cover;
private string _series;
private string _title;
private string _authors;
private string _narrators;
private string _category;
private string _misc;
private string _description;
private Rating _productrating;
private string _bookTags;
private Rating _myRating;
public abstract bool? Remove { get; set; }
public EntryStatus Liberate { get; private set; }
public string PurchaseDate { get => _purchasedate; protected set => RaiseAndSetIfChanged(ref _purchasedate, value); }
public string Length { get => _length; protected set => RaiseAndSetIfChanged(ref _length, value); }
public LastDownloadStatus LastDownload { get => _lastDownload; protected set => RaiseAndSetIfChanged(ref _lastDownload, value); }
public object Cover { get => _cover; private set => RaiseAndSetIfChanged(ref _cover, value); }
public string Series { get => _series; private set => RaiseAndSetIfChanged(ref _series, value); }
public string Title { get => _title; private set => RaiseAndSetIfChanged(ref _title, value); }
public string Authors { get => _authors; private set => RaiseAndSetIfChanged(ref _authors, value); }
public string Narrators { get => _narrators; private set => RaiseAndSetIfChanged(ref _narrators, value); }
public string Category { get => _category; private set => RaiseAndSetIfChanged(ref _category, value); }
public string Misc { get => _misc; private set => RaiseAndSetIfChanged(ref _misc, value); }
public string Description { get => _description; private set => RaiseAndSetIfChanged(ref _description, value); }
public Rating ProductRating { get => _productrating; private set => RaiseAndSetIfChanged(ref _productrating, value); }
public string BookTags { get => _bookTags; private set => RaiseAndSetIfChanged(ref _bookTags, value); }
public Rating MyRating
{
get => _myRating;
set
{
if (_myRating != value && value.OverallRating != 0 && updateReviewTask?.IsCompleted is not false)
updateReviewTask = UpdateRating(value);
}
}
#endregion
#region User rating
private Task updateReviewTask;
private async Task UpdateRating(Rating rating)
{
var api = await LibraryBook.GetApiAsync();
if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating))
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating);
}
#endregion
#region View property updating
public void UpdateLibraryBook(LibraryBook libraryBook)
{
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
LibraryBook = libraryBook;
Liberate = TStatus.Create(libraryBook);
Title = Book.Title;
Series = Book.SeriesNames();
Length = GetBookLengthString();
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
//the reference doesn't change. Clone the rating so that it updates within Avalonia properly.
_myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating);
PurchaseDate = GetPurchaseDateString();
ProductRating = Book.Rating ?? new Rating(0, 0, 0);
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LastDownload = new(Book.UserDefinedItem);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
BookTags = GetBookTags();
RaisePropertyChanged(nameof(MyRating));
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
protected abstract string GetBookTags();
protected virtual DateTime GetPurchaseDate() => LibraryBook.DateAdded;
protected virtual int GetLengthInMinutes() => Book.LengthInMinutes;
protected string GetPurchaseDateString() => GetPurchaseDate().ToString("d");
protected string GetBookLengthString()
{
int bookLenMins = GetLengthInMinutes();
return bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
}
#endregion
#region detect changes to the model, update the view.
/// <summary>
/// This event handler receives notifications from the model that it has changed.
/// Notify the view that it's changed.
/// </summary>
private void UserDefinedItem_ItemChanged(object sender, string itemName)
{
var udi = sender as UserDefinedItem;
if (udi.Book.AudibleProductId != Book.AudibleProductId)
return;
// UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view.
// - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs
// - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view.
switch (itemName)
{
case nameof(udi.BookStatus):
case nameof(udi.PdfStatus):
Liberate.Invalidate(nameof(Liberate.BookStatus), nameof(Liberate.PdfStatus), nameof(Liberate.IsUnavailable), nameof(Liberate.ButtonImage), nameof(Liberate.ToolTip));
RaisePropertyChanged(nameof(Liberate));
break;
case nameof(udi.Tags):
BookTags = GetBookTags();
Liberate.Invalidate(nameof(Liberate.Opacity));
RaisePropertyChanged(nameof(Liberate));
break;
case nameof(udi.LastDownloaded):
LastDownload = new (udi);
break;
case nameof(udi.Rating):
_myRating = udi.Rating;
RaisePropertyChanged(nameof(MyRating));
break;
}
}
private TRet RaiseAndSetIfChanged<TRet>(ref TRet backingField, TRet newValue, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<TRet>.Default.Equals(backingField, newValue)) return newValue;
backingField = newValue;
RaisePropertyChanged(new PropertyChangedEventArgs(propertyName));
return newValue;
}
public event PropertyChangedEventHandler PropertyChanged;
public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args));
public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName));
#endregion
#region Sorting
public GridEntry()
{
memberValues = new()
{
{ nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => GetLengthInMinutes() },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating },
{ nameof(PurchaseDate), () => GetPurchaseDate() },
{ nameof(ProductRating), () => Book.Rating },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(BookTags), () => BookTags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
}
public object GetMemberValue(string memberName) => memberValues[memberName]();
public IComparer GetMemberComparer(Type memberType)
=> memberTypeComparers.TryGetValue(memberType, out IComparer value) ? value : memberTypeComparers[memberType.BaseType];
private readonly Dictionary<string, Func<object>> memberValues;
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> memberTypeComparers = new()
{
{ typeof(RemoveStatus), new ObjectComparer<RemoveStatus>() },
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(Rating), new ObjectComparer<Rating>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(EntryStatus), new ObjectComparer<EntryStatus>() },
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
};
#endregion
#region Cover Art
protected void LoadCover()
{
// Get cover art. If it's default, subscribe to PictureCached
(bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
if (isDefault)
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
_cover = Liberate.LoadImage(picture);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
// state validation
if (e?.Definition.PictureId is null ||
Book?.PictureId is null ||
e.Picture?.Length == 0)
return;
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
Cover = Liberate.LoadImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
#endregion
#region Static library display functions
/// <summary>This information should not change during <see cref="GridEntry"/> lifetime, so call only once.</summary>
private static string GetDescriptionDisplay(Book book)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
private static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
private static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]");
var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]");
details.Add($"Account: {locale} - {acct}");
if (libraryBook.Book.HasPdf())
details.Add("Has PDF");
if (libraryBook.Book.IsAbridged)
details.Add("Abridged");
if (libraryBook.Book.DatePublished.HasValue)
details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}");
// this goes last since it's most likely to have a line-break
if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher))
details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}");
if (!details.Any())
return "[details not imported]";
return string.Join("\r\n", details);
}
#endregion
~GridEntry()
{
PictureStorage.PictureCached -= PictureStorage_PictureCached;
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
}
}
}

View File

@ -0,0 +1,34 @@
using DataLayer;
using Dinah.Core.DataBinding;
using System;
using System.ComponentModel;
namespace LibationUiBase.GridView
{
public interface IGridEntry : IMemberComparable, INotifyPropertyChanged
{
EntryStatus Liberate { get; }
float SeriesIndex { get; }
string AudibleProductId { get; }
string LongDescription { get; }
LibraryBook LibraryBook { get; }
Book Book { get; }
DateTime DateAdded { get; }
bool? Remove { get; set; }
string PurchaseDate { get; }
object Cover { get; }
string Length { get; }
LastDownloadStatus LastDownload { get; }
string Series { get; }
string Title { get; }
string Authors { get; }
string Narrators { get; }
string Category { get; }
string Misc { get; }
string Description { get; }
Rating ProductRating { get; }
Rating MyRating { get; set; }
string BookTags { get; }
void UpdateLibraryBook(LibraryBook libraryBook);
}
}

View File

@ -0,0 +1,7 @@
namespace LibationUiBase.GridView
{
public interface ILibraryBookEntry : IGridEntry
{
ISeriesEntry Parent { get; }
}
}

View File

@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace LibationUiBase.GridView
{
public interface ISeriesEntry : IGridEntry
{
List<ILibraryBookEntry> Children { get; }
void ChildRemoveUpdate();
void RemoveChild(ILibraryBookEntry libraryBookEntry);
}
}

View File

@ -1,7 +1,7 @@
using DataLayer;
using System;
namespace LibationUiBase
namespace LibationUiBase.GridView
{
public class LastDownloadStatus : IComparable
{

View File

@ -0,0 +1,34 @@
using DataLayer;
using System;
using System.ComponentModel;
namespace LibationUiBase.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
public class LibraryBookEntry<TStatus> : GridEntry<TStatus>, ILibraryBookEntry where TStatus : IEntryStatus
{
[Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
[Browsable(false)] public ISeriesEntry Parent { get; }
public override bool? Remove
{
get => remove;
set
{
remove = value ?? false;
Parent?.ChildRemoveUpdate();
RaisePropertyChanged(nameof(Remove));
}
}
public LibraryBookEntry(LibraryBook libraryBook, ISeriesEntry parent = null)
{
Parent = parent;
UpdateLibraryBook(libraryBook);
LoadCover();
}
protected override string GetBookTags() => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
}
}

View File

@ -1,7 +1,7 @@
using System;
using System.Collections;
namespace LibationUiBase
namespace LibationUiBase.GridView
{
public class ObjectComparer<T> : IComparer where T : IComparable
{

View File

@ -3,24 +3,24 @@ using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationAvalonia.ViewModels
namespace LibationUiBase.GridView
{
#nullable enable
internal static class QueryExtensions
public static class QueryExtensions
{
public static IEnumerable<LibraryBookEntry> BookEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static IEnumerable<ILibraryBookEntry> BookEntries(this IEnumerable<IGridEntry> gridEntries)
=> gridEntries.OfType<ILibraryBookEntry>();
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static IEnumerable<ISeriesEntry> SeriesEntries(this IEnumerable<IGridEntry> gridEntries)
=> gridEntries.OfType<ISeriesEntry>();
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : IGridEntry
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
public static IEnumerable<ISeriesEntry> EmptySeries(this IEnumerable<IGridEntry> gridEntries)
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
public static ISeriesEntry? FindSeriesParent(this IEnumerable<IGridEntry> gridEntries, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;

View File

@ -0,0 +1,68 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationUiBase.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry<TStatus> : GridEntry<TStatus>, ISeriesEntry where TStatus : IEntryStatus
{
public List<ILibraryBookEntry> Children { get; }
public override DateTime DateAdded => Children.Max(c => c.DateAdded);
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove == true);
remove = removeCount == 0 ? false : removeCount == Children.Count ? true : null;
RaisePropertyChanged(nameof(Remove));
}
public override bool? Remove
{
get => remove;
set
{
remove = value ?? false;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
RaisePropertyChanged(nameof(Remove));
}
}
public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent, new[] { child }) { }
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children)
{
LastDownload = new();
SeriesIndex = -1;
Children = children
.Select(c => new LibraryBookEntry<TStatus>(c, this))
.OrderBy(c => c.SeriesIndex)
.ToList<ILibraryBookEntry>();
UpdateLibraryBook(parent);
LoadCover();
}
public void RemoveChild(ILibraryBookEntry lbe)
{
Children.Remove(lbe);
PurchaseDate = GetPurchaseDateString();
Length = GetBookLengthString();
}
protected override string GetBookTags() => null;
protected override int GetLengthInMinutes() => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
protected override DateTime GetPurchaseDate() => Children.Min(c => c.LibraryBook.DateAdded);
}
}

View File

@ -1,10 +1,10 @@
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SixLabors.ImageSharp.Formats;
namespace LibationUiBase
{

View File

@ -1,10 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace LibationUiBase
namespace LibationUiBase
{
public class SampleRateSelection
{

View File

@ -1,6 +1,7 @@
using System.Drawing;
using System.Windows.Forms;
using Dinah.Core.WindowsDesktop.Forms;
using LibationUiBase.GridView;
namespace LibationWinForms.GridView
{
@ -15,34 +16,18 @@ namespace LibationWinForms.GridView
internal class EditTagsDataGridViewImageButtonCell : DataGridViewImageButtonCell
{
private static Image ButtonImage { get; } = Properties.Resources.edit_25x25;
private static Color HiddenForeColor { get; } = Color.LightGray;
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
{
if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is SeriesEntry)
{
if (rowIndex >= 0 && DataGridView.GetBoundItem<IGridEntry>(rowIndex) is ISeriesEntry)
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border);
return;
}
var tagsString = (string)value;
var foreColor = tagsString?.Contains("hidden") == true ? HiddenForeColor : DataGridView.DefaultCellStyle.ForeColor;
if (DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor != foreColor)
{
DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor = foreColor;
}
if (tagsString?.Length == 0)
else if (value is string tagStr && tagStr.Length == 0)
{
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
DrawButtonImage(graphics, ButtonImage, cellBounds);
}
else
{
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts);
}
}
}
}

View File

@ -1,233 +0,0 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.DataBinding;
using Dinah.Core.WindowsDesktop.Drawing;
using FileLiberator;
using LibationFileManager;
using LibationUiBase;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
namespace LibationWinForms.GridView
{
public enum RemoveStatus
{
NotRemoved,
Removed,
SomeRemoved
}
/// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable
{
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
[Browsable(false)] public float SeriesIndex { get; protected set; }
[Browsable(false)] public string LongDescription { get; protected set; }
[Browsable(false)] public abstract DateTime DateAdded { get; }
[Browsable(false)] public Book Book => LibraryBook.Book;
[Browsable(false)] public abstract bool IsSeries { get; }
[Browsable(false)] public abstract bool IsEpisode { get; }
[Browsable(false)] public abstract bool IsBook { get; }
#region Model properties exposed to the view
protected RemoveStatus _remove = RemoveStatus.NotRemoved;
public abstract RemoveStatus Remove { get; set; }
public abstract LiberateButtonStatus Liberate { get; }
public Image Cover
{
get => _cover;
protected set
{
_cover = value;
NotifyPropertyChanged();
}
}
public string PurchaseDate { get; protected set; }
public string Series { get; protected set; }
public string Title { get; protected set; }
public string Length { get; protected set; }
public string Authors { get; protected set; }
public string Narrators { get; protected set; }
public string Category { get; protected set; }
public string Misc { get; protected set; }
public virtual LastDownloadStatus LastDownload { get; protected set; } = new();
public string Description { get; protected set; }
public string ProductRating { get; protected set; }
protected Rating _myRating;
public Rating MyRating
{
get => _myRating;
set
{
if (_myRating != value
&& value.OverallRating != 0
&& updateReviewTask?.IsCompleted is not false)
{
updateReviewTask = UpdateRating(value);
updateReviewTask.ContinueWith(t =>
{
if (t.Result)
{
_myRating = value;
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, value);
}
NotifyPropertyChanged();
});
}
}
}
public abstract string DisplayTags { get; }
#endregion
#region User rating
private Task<bool> updateReviewTask;
private async Task<bool> UpdateRating(Rating rating)
{
var api = await LibraryBook.GetApiAsync();
return await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating);
}
#endregion
#region Sorting
public GridEntry() => _memberValues = CreateMemberValueDictionary();
// These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
// Used by GridEntryBindingList for all sorting
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
protected abstract Dictionary<string, Func<object>> CreateMemberValueDictionary();
private Dictionary<string, Func<object>> _memberValues { get; set; }
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new()
{
{ typeof(RemoveStatus), new ObjectComparer<RemoveStatus>() },
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
};
#endregion
#region Cover Art
private Image _cover;
protected void LoadCover()
{
// Get cover art. If it's default, subscribe to PictureCached
(bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
if (isDefault)
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
_cover = loadImage(picture);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
// state validation
if (e is null ||
e.Definition.PictureId is null ||
Book?.PictureId is null ||
e.Picture is null ||
e.Picture.Length == 0)
return;
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
Cover = loadImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
private Image loadImage(byte[] picture)
{
try
{
return ImageReader.ToImage(picture);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book);
return Properties.Resources.default_cover_80x80;
}
}
#endregion
#region Static library display functions
/// <summary>This information should not change during <see cref="GridEntry"/> lifetime, so call only once.</summary>
protected static string GetDescriptionDisplay(Book book)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
protected static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
protected static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
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;
}
}
}

View File

@ -1,6 +1,7 @@
using ApplicationServices;
using Dinah.Core.DataBinding;
using LibationSearchEngine;
using LibationUiBase.GridView;
using System;
using System.Collections.Generic;
using System.ComponentModel;
@ -20,20 +21,20 @@ namespace LibationWinForms.GridView
* Remove is overridden to ensure that removed items are removed from
* the base list (visible items) as well as the FilterRemoved list.
*/
internal class GridEntryBindingList : BindingList<GridEntry>, IBindingListView
internal class GridEntryBindingList : BindingList<IGridEntry>, IBindingListView
{
public GridEntryBindingList() : base(new List<GridEntry>()) { }
public GridEntryBindingList(IEnumerable<GridEntry> enumeration) : base(new List<GridEntry>(enumeration)) { }
public GridEntryBindingList() : base(new List<IGridEntry>()) { }
public GridEntryBindingList(IEnumerable<IGridEntry> enumeration) : base(new List<IGridEntry>(enumeration)) { }
/// <returns>All items in the list, including those filtered out.</returns>
public List<GridEntry> AllItems() => Items.Concat(FilterRemoved).ToList();
public List<IGridEntry> AllItems() => Items.Concat(FilterRemoved).ToList();
public bool SupportsFiltering => true;
public string Filter { get => FilterString; set => ApplyFilter(value); }
/// <summary>When true, itms will not be checked filtered by search criteria on item changed<summary>
public bool SuspendFilteringOnUpdate { get; set; }
protected MemberComparer<GridEntry> Comparer { get; } = new();
protected MemberComparer<IGridEntry> Comparer { get; } = new();
protected override bool SupportsSortingCore => true;
protected override bool SupportsSearchingCore => true;
protected override bool IsSortedCore => isSorted;
@ -41,7 +42,7 @@ namespace LibationWinForms.GridView
protected override ListSortDirection SortDirectionCore => listSortDirection;
/// <summary> Items that were removed from the base list due to filtering </summary>
private readonly List<GridEntry> FilterRemoved = new();
private readonly List<IGridEntry> FilterRemoved = new();
private string FilterString;
private SearchResultSet SearchResults;
private bool isSorted;
@ -59,7 +60,7 @@ namespace LibationWinForms.GridView
public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException();
#endregion
public new void Remove(GridEntry entry)
public new void Remove(IGridEntry entry)
{
FilterRemoved.Remove(entry);
base.Remove(entry);
@ -73,7 +74,7 @@ namespace LibationWinForms.GridView
FilterString = filterString;
SearchResults = SearchEngineCommands.Search(filterString);
var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe);
var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe);
//Find all series containing children that match the search criteria
var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any());
@ -99,7 +100,7 @@ namespace LibationWinForms.GridView
ExpandItem(series);
}
public void CollapseItem(SeriesEntry sEntry)
public void CollapseItem(ISeriesEntry sEntry)
{
foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList())
{
@ -110,7 +111,7 @@ namespace LibationWinForms.GridView
sEntry.Liberate.Expanded = false;
}
public void ExpandItem(SeriesEntry sEntry)
public void ExpandItem(ISeriesEntry sEntry)
{
var sindex = Items.IndexOf(sEntry);
@ -133,7 +134,7 @@ namespace LibationWinForms.GridView
foreach (var item in FilterRemoved.ToList())
{
if (item is SeriesEntry || (item is LibraryBookEntry lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded)))
if (item is ISeriesEntry || (item is ILibraryBookEntry lbe && (lbe.Liberate.IsBook || lbe.Parent.Liberate.Expanded)))
{
FilterRemoved.Remove(item);
InsertItem(visibleCount++, item);
@ -145,7 +146,7 @@ namespace LibationWinForms.GridView
else
//No user sort is applied, so do default sorting by DateAdded, descending
{
Comparer.PropertyName = nameof(GridEntry.DateAdded);
Comparer.PropertyName = nameof(IGridEntry.DateAdded);
Comparer.Direction = ListSortDirection.Descending;
Sort();
}
@ -172,9 +173,9 @@ namespace LibationWinForms.GridView
protected void Sort()
{
var itemsList = (List<GridEntry>)Items;
var itemsList = (List<IGridEntry>)Items;
var children = itemsList.BookEntries().Where(i => i.Parent is not null).ToList();
var children = itemsList.BookEntries().Where(i => i.Liberate.IsEpisode).ToList();
var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList();
@ -198,7 +199,7 @@ namespace LibationWinForms.GridView
{
if (e.ListChangedType == ListChangedType.ItemChanged)
{
if (FilterString is not null && !SuspendFilteringOnUpdate && Items[e.NewIndex] is LibraryBookEntry lbItem)
if (FilterString is not null && !SuspendFilteringOnUpdate && Items[e.NewIndex] is ILibraryBookEntry lbItem)
{
SearchResults = SearchEngineCommands.Search(FilterString);
if (!SearchResults.Docs.Any(d => d.ProductId == lbItem.AudibleProductId))

View File

@ -1,4 +1,4 @@
using LibationUiBase;
using LibationUiBase.GridView;
using System;
using System.Drawing;
using System.Windows.Forms;
@ -26,7 +26,8 @@ namespace LibationWinForms.GridView
private LastDownloadStatus LastDownload => (LastDownloadStatus)Value;
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
{
ToolTipText = ((LastDownloadStatus)value).ToolTipText;
if (value is LastDownloadStatus lastDl)
ToolTipText = lastDl.ToolTipText;
base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts);
}

View File

@ -1,38 +0,0 @@
using DataLayer;
using System;
namespace LibationWinForms.GridView
{
public class LiberateButtonStatus : IComparable
{
public LiberatedStatus BookStatus { get; set; }
public LiberatedStatus? PdfStatus { get; set; }
public bool Expanded { get; set; }
public bool IsSeries { get; }
private bool IsAbsent { get; }
public bool IsUnavailable => !IsSeries & IsAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
public LiberateButtonStatus(bool isSeries, bool isAbsent)
{
IsSeries = isSeries;
IsAbsent = isAbsent;
}
/// <summary>
/// Defines the Liberate column's sorting behavior
/// </summary>
public int CompareTo(object obj)
{
if (obj is not LiberateButtonStatus second) return -1;
if (IsSeries && !second.IsSeries) return -1;
else if (!IsSeries && second.IsSeries) return 1;
else if (IsSeries && second.IsSeries) return 0;
else if (IsUnavailable && !second.IsUnavailable) return 1;
else if (!IsUnavailable && second.IsUnavailable) return -1;
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
else return BookStatus.CompareTo(second.BookStatus);
}
}
}

View File

@ -1,8 +1,6 @@
using System;
using DataLayer;
using System.Drawing;
using System.Windows.Forms;
using DataLayer;
using Dinah.Core.WindowsDesktop.Forms;
namespace LibationWinForms.GridView
{
@ -16,78 +14,26 @@ namespace LibationWinForms.GridView
internal class LiberateDataGridViewImageButtonCell : DataGridViewImageButtonCell
{
private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230);
private static readonly Brush DISABLED_GRAY = new SolidBrush(Color.FromArgb(0x60, Color.LightGray));
private static readonly Color HiddenForeColor = Color.LightGray;
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
{
if (value is LiberateButtonStatus status)
if (value is WinFormsEntryStatus status)
{
if (status.BookStatus is LiberatedStatus.Error || status.IsUnavailable)
//Don't paint the button graphic
paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground;
if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is LibraryBookEntry lbEntry && lbEntry.Parent is not null)
DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = SERIES_BG_COLOR;
DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = (Color)status.BackgroundBrush;
DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor = status.Opacity == 1 ? DataGridView.DefaultCellStyle.ForeColor : HiddenForeColor;
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
if (status.IsSeries)
{
DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus : Properties.Resources.plus, cellBounds);
DrawButtonImage(graphics, (Image)status.ButtonImage, cellBounds);
ToolTipText = status.ToolTip;
ToolTipText = status.Expanded ? "Click to Collpase" : "Click to Expand";
}
else
{
(string mouseoverText, Bitmap buttonImage) = GetLiberateDisplay(status.BookStatus, status.PdfStatus);
DrawButtonImage(graphics, buttonImage, cellBounds);
if (status.IsUnavailable)
{
//Create the "disabled" look by painting a transparent gray box over the buttom image.
if (status.IsUnavailable || status.Opacity < 1)
graphics.FillRectangle(DISABLED_GRAY, cellBounds);
ToolTipText = "This book cannot be downloaded\r\nbecause it wasn't found during\r\nthe most recent library scan";
}
else
ToolTipText = mouseoverText;
}
}
}
private static (string mouseoverText, Bitmap buttonImage) GetLiberateDisplay(LiberatedStatus liberatedStatus, LiberatedStatus? pdfStatus)
{
if (liberatedStatus == LiberatedStatus.Error)
return ("Book downloaded ERROR", Properties.Resources.error);
(string libState, string image_lib) = liberatedStatus switch
{
LiberatedStatus.Liberated => ("Liberated", "green"),
LiberatedStatus.PartialDownload => ("File has been at least\r\npartially downloaded", "yellow"),
LiberatedStatus.NotLiberated => ("Book NOT downloaded", "red"),
_ => throw new Exception("Unexpected liberation state")
};
(string pdfState, string image_pdf) = pdfStatus switch
{
LiberatedStatus.Liberated => ("\r\nPDF downloaded", "_pdf_yes"),
LiberatedStatus.NotLiberated => ("\r\nPDF NOT downloaded", "_pdf_no"),
LiberatedStatus.Error => ("\r\nPDF downloaded ERROR", "_pdf_no"),
null => ("", ""),
_ => throw new Exception("Unexpected PDF state")
};
var mouseoverText = libState + pdfState;
if (liberatedStatus == LiberatedStatus.NotLiberated ||
liberatedStatus == LiberatedStatus.PartialDownload ||
pdfStatus == LiberatedStatus.NotLiberated)
mouseoverText += "\r\nClick to complete";
var buttonImage = (Bitmap)Properties.Resources.ResourceManager.GetObject($"liberate_{image_lib}{image_pdf}");
return (mouseoverText, buttonImage);
}
}
}

View File

@ -1,177 +0,0 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using LibationUiBase;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationWinForms.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
public class LibraryBookEntry : GridEntry
{
[Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
[Browsable(false)] public SeriesEntry Parent { get; init; }
[Browsable(false)] public override bool IsSeries => false;
[Browsable(false)] public override bool IsEpisode => Parent is not null;
[Browsable(false)] public override bool IsBook => Parent is null;
#region Model properties exposed to the view
private DateTime lastStatusUpdate = default;
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public override LastDownloadStatus LastDownload { get; protected set; }
public override RemoveStatus Remove
{
get
{
return _remove;
}
set
{
_remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value;
Parent?.ChildRemoveUpdate();
NotifyPropertyChanged();
}
}
public override LiberateButtonStatus Liberate
{
get
{
//Cache these statuses for faster sorting.
if ((DateTime.Now - lastStatusUpdate).TotalSeconds > 2)
{
_bookStatus = LibraryCommands.Liberated_Status(LibraryBook.Book);
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
lastStatusUpdate = DateTime.Now;
}
return new LiberateButtonStatus(isSeries: false, LibraryBook.AbsentFromLastScan) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
}
}
public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
#endregion
public LibraryBookEntry(LibraryBook libraryBook)
{
setLibraryBook(libraryBook);
LoadCover();
}
public void UpdateLibraryBook(LibraryBook libraryBook)
{
if (AudibleProductId != libraryBook.Book.AudibleProductId)
throw new Exception("Invalid grid entry update. IDs must match");
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
setLibraryBook(libraryBook);
NotifyPropertyChanged();
}
private void setLibraryBook(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
Title = Book.Title;
Series = Book.SeriesNames();
Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min";
_myRating = Book.UserDefinedItem.Rating;
PurchaseDate = libraryBook.DateAdded.ToString("d");
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LastDownload = new(Book.UserDefinedItem);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
#region detect changes to the model, update the view, and save to database.
/// <summary>
/// This event handler receives notifications from the model that it has changed.
/// Notify the view that it's changed.
/// </summary>
private void UserDefinedItem_ItemChanged(object sender, string itemName)
{
var udi = sender as UserDefinedItem;
if (udi.Book.AudibleProductId != Book.AudibleProductId)
return;
// UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view.
// - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs
// - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view.
switch (itemName)
{
case nameof(udi.Tags):
Book.UserDefinedItem.Tags = udi.Tags;
NotifyPropertyChanged(nameof(DisplayTags));
break;
case nameof(udi.BookStatus):
Book.UserDefinedItem.BookStatus = udi.BookStatus;
_bookStatus = udi.BookStatus;
NotifyPropertyChanged(nameof(Liberate));
break;
case nameof(udi.PdfStatus):
Book.UserDefinedItem.SetPdfStatus(udi.PdfStatus);
_pdfStatus = udi.PdfStatus;
NotifyPropertyChanged(nameof(Liberate));
break;
case nameof(udi.LastDownloaded):
LastDownload = new(udi);
NotifyPropertyChanged(nameof(LastDownload));
break;
}
}
/// <summary>Save edits to the database</summary>
public void Commit(string newTags, LiberatedStatus bookStatus, LiberatedStatus? pdfStatus)
// MVVM pass-through
=> Book.UpdateUserDefinedItem(newTags, bookStatus: bookStatus, pdfStatus: pdfStatus);
#endregion
#region Data Sorting
/// <summary>Create getters for all member object values by name </summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Remove), () => Remove },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Book.LengthInMinutes },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
{ nameof(PurchaseDate), () => LibraryBook.DateAdded },
{ nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(DisplayTags), () => DisplayTags },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
#endregion
~LibraryBookEntry()
{
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
}
}
}

View File

@ -31,7 +31,7 @@ namespace LibationWinForms.GridView
public override Type EditType => typeof(MyRatingCellEditor);
public override Type ValueType => typeof(Rating);
public MyRatingGridViewCell() { ToolTipText = "Click to change ratings"; }
public MyRatingGridViewCell() { ToolTipText = ReadOnly ? "" : "Click to change ratings"; }
public override void InitializeEditingControl(int rowIndex, object initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle)
{
@ -46,7 +46,7 @@ namespace LibationWinForms.GridView
{
if (value is Rating rating)
{
ToolTipText = "Click to change ratings";
ToolTipText = ReadOnly ? "" : "Click to change ratings";
var starString = rating.ToStarString();
base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, starString, starString, errorText, cellStyle, advancedBorderStyle, paintParts);

View File

@ -3,6 +3,7 @@ using AudibleUtilities;
using DataLayer;
using FileLiberator;
using LibationFileManager;
using LibationUiBase.GridView;
using LibationWinForms.Dialogs;
using System;
using System.Collections.Generic;
@ -32,7 +33,7 @@ namespace LibationWinForms.GridView
#region Button controls
private ImageDisplay imageDisplay;
private void productsGrid_CoverClicked(GridEntry liveGridEntry)
private void productsGrid_CoverClicked(IGridEntry liveGridEntry)
{
var picDef = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native);
@ -67,7 +68,7 @@ namespace LibationWinForms.GridView
imageDisplay.Show(null);
}
private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle)
private void productsGrid_DescriptionClicked(IGridEntry liveGridEntry, Rectangle cellRectangle)
{
var displayWindow = new DescriptionDisplay
{
@ -86,11 +87,11 @@ namespace LibationWinForms.GridView
displayWindow.Show(this);
}
private void productsGrid_DetailsClicked(LibraryBookEntry liveGridEntry)
private void productsGrid_DetailsClicked(ILibraryBookEntry liveGridEntry)
{
var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook);
if (bookDetailsForm.ShowDialog() == DialogResult.OK)
liveGridEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
liveGridEntry.Book.UpdateUserDefinedItem(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
}
#endregion
@ -102,7 +103,7 @@ namespace LibationWinForms.GridView
public async Task RemoveCheckedBooksAsync()
{
var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is RemoveStatus.Removed).ToList();
var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is true).ToList();
if (selectedBooks.Count == 0)
return;
@ -141,7 +142,7 @@ namespace LibationWinForms.GridView
var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList();
foreach (var r in removable)
r.Remove = RemoveStatus.Removed;
r.Remove = true;
productsGrid_RemovableCountChanged(this, null);
}
@ -198,14 +199,14 @@ namespace LibationWinForms.GridView
VisibleCountChanged?.Invoke(this, count);
}
private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry)
private void productsGrid_LiberateClicked(ILibraryBookEntry liveGridEntry)
{
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error
&& !liveGridEntry.Liberate.IsUnavailable)
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
}
private void productsGrid_ConvertToMp3Clicked(LibraryBookEntry liveGridEntry)
private void productsGrid_ConvertToMp3Clicked(ILibraryBookEntry liveGridEntry)
{
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error)
ConvertToMp3Clicked?.Invoke(this, liveGridEntry.LibraryBook);
@ -213,7 +214,7 @@ namespace LibationWinForms.GridView
private void productsGrid_RemovableCountChanged(object sender, EventArgs e)
{
RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is RemoveStatus.Removed));
RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is true));
}
}
}

View File

@ -1,4 +1,6 @@
namespace LibationWinForms.GridView
using LibationUiBase.GridView;
namespace LibationWinForms.GridView
{
partial class ProductsGrid
{
@ -41,7 +43,7 @@
this.seriesGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.descriptionGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.categoryGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.productRatingGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.productRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn();
this.purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.myRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn();
this.miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
@ -193,6 +195,7 @@
this.productRatingGVColumn.HeaderText = "Product Rating";
this.productRatingGVColumn.Name = "productRatingGVColumn";
this.productRatingGVColumn.ReadOnly = true;
this.productRatingGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
this.productRatingGVColumn.Width = 108;
//
// purchaseDateGVColumn
@ -229,7 +232,7 @@
//
// tagAndDetailsGVColumn
//
this.tagAndDetailsGVColumn.DataPropertyName = "DisplayTags";
this.tagAndDetailsGVColumn.DataPropertyName = "BookTags";
this.tagAndDetailsGVColumn.HeaderText = "Tags and Details";
this.tagAndDetailsGVColumn.Name = "tagAndDetailsGVColumn";
this.tagAndDetailsGVColumn.ReadOnly = true;
@ -243,7 +246,7 @@
//
// syncBindingSource
//
this.syncBindingSource.DataSource = typeof(LibationWinForms.GridView.GridEntry);
this.syncBindingSource.DataSource = typeof(IGridEntry);
//
// ProductsGrid
//
@ -275,7 +278,7 @@
private System.Windows.Forms.DataGridViewTextBoxColumn seriesGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn descriptionGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn categoryGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn productRatingGVColumn;
private MyRatingGridViewColumn productRatingGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn;
private MyRatingGridViewColumn myRatingGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn;

View File

@ -1,21 +1,23 @@
using System;
using ApplicationServices;
using DataLayer;
using Dinah.Core.WindowsDesktop.Forms;
using LibationFileManager;
using LibationUiBase.GridView;
using LibationWinForms.Dialogs;
using System;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using ApplicationServices;
using DataLayer;
using Dinah.Core.WindowsDesktop.Forms;
using LibationFileManager;
using LibationWinForms.Dialogs;
namespace LibationWinForms.GridView
{
public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry);
public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry);
public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle);
public delegate void GridEntryClickedEventHandler(IGridEntry liveGridEntry);
public delegate void LibraryBookEntryClickedEventHandler(ILibraryBookEntry liveGridEntry);
public delegate void GridEntryRectangleClickedEventHandler(IGridEntry liveGridEntry, Rectangle cellRectangle);
public partial class ProductsGrid : UserControl
{
@ -34,7 +36,7 @@ namespace LibationWinForms.GridView
=> bindingList
.BookEntries()
.Select(lbe => lbe.LibraryBook);
internal IEnumerable<LibraryBookEntry> GetAllBookEntries()
internal IEnumerable<ILibraryBookEntry> GetAllBookEntries()
=> bindingList.AllItems().BookEntries();
public ProductsGrid()
@ -62,7 +64,7 @@ namespace LibationWinForms.GridView
return;
var entry = getGridEntry(e.RowIndex);
if (entry is LibraryBookEntry lbEntry)
if (entry is ILibraryBookEntry lbEntry)
{
if (e.ColumnIndex == liberateGVColumn.Index)
LiberateClicked?.Invoke(lbEntry);
@ -73,7 +75,7 @@ namespace LibationWinForms.GridView
else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(lbEntry);
}
else if (entry is SeriesEntry sEntry)
else if (entry is ISeriesEntry sEntry)
{
if (e.ColumnIndex == liberateGVColumn.Index)
{
@ -82,8 +84,6 @@ namespace LibationWinForms.GridView
else
bindingList.ExpandItem(sEntry);
sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate));
VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
}
else if (e.ColumnIndex == descriptionGVColumn.Index)
@ -136,7 +136,7 @@ namespace LibationWinForms.GridView
// else: stop light
var entry = getGridEntry(e.RowIndex);
if (entry.IsSeries)
if (entry.Liberate.IsSeries)
return;
var setDownloadMenuItem = new ToolStripMenuItem()
@ -182,7 +182,7 @@ namespace LibationWinForms.GridView
Text = "&Convert to Mp3",
Enabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated
};
convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(entry as LibraryBookEntry);
convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(entry as ILibraryBookEntry);
var bookRecordMenuItem = new ToolStripMenuItem { Text = "View &Bookmarks/Clips" };
bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this);
@ -199,7 +199,7 @@ namespace LibationWinForms.GridView
e.ContextMenuStrip = stopLightContextMenu;
}
private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<GridEntry>(rowIndex);
private IGridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<IGridEntry>(rowIndex);
#endregion
@ -213,7 +213,7 @@ namespace LibationWinForms.GridView
if (value)
{
foreach (var book in bindingList.AllItems())
book.Remove = RemoveStatus.NotRemoved;
book.Remove = false;
}
removeGVColumn.DisplayIndex = 0;
@ -226,9 +226,8 @@ namespace LibationWinForms.GridView
{
var geList = dbBooks
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry(b))
.Cast<GridEntry>()
.ToList();
.Select(b => new LibraryBookEntry<WinFormsEntryStatus>(b))
.ToList<IGridEntry>();
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
@ -240,7 +239,7 @@ namespace LibationWinForms.GridView
if (!seriesEpisodes.Any()) continue;
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
var seriesEntry = new SeriesEntry<WinFormsEntryStatus>(parent, seriesEpisodes);
geList.Add(seriesEntry);
geList.AddRange(seriesEntry.Children);
@ -268,16 +267,26 @@ namespace LibationWinForms.GridView
var seriesEntries = bindingList.AllItems().SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToList();
var sw = new Stopwatch();
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
if (libraryBook.Book.IsProduct())
{
AddOrUpdateBook(libraryBook, existingEntry);
else if(parentedEpisodes.Any(lb => lb == libraryBook))
continue;
}
sw.Start();
if (parentedEpisodes.Any(lb => lb == libraryBook))
{
sw.Stop();
//Only try to add or update is this LibraryBook is a know child of a parent
AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
sw.Stop();
}
bindingList.SuspendFilteringOnUpdate = false;
@ -297,14 +306,11 @@ namespace LibationWinForms.GridView
RemoveBooks(removedBooks);
}
public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks)
public void RemoveBooks(IEnumerable<ILibraryBookEntry> removedBooks)
{
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Parent is not null))
{
removed.Parent.Children.Remove(removed);
removed.Parent.NotifyPropertyChanged();
}
foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode))
removed.Parent.RemoveChild(removed);
//Remove series that have no children
var removedSeries =
@ -312,28 +318,28 @@ namespace LibationWinForms.GridView
.AllItems()
.EmptySeries();
foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries))
foreach (var removed in removedBooks.Cast<IGridEntry>().Concat(removedSeries))
//no need to re-filter for removed books
bindingList.Remove(removed);
VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
}
private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry existingBookEntry)
private void AddOrUpdateBook(LibraryBook book, ILibraryBookEntry existingBookEntry)
{
if (existingBookEntry is null)
// Add the new product to top
bindingList.Insert(0, new LibraryBookEntry(book));
bindingList.Insert(0, new LibraryBookEntry<WinFormsEntryStatus>(book));
else
// update existing
existingBookEntry.UpdateLibraryBook(book);
}
private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List<ISeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (existingEpisodeEntry is null)
{
LibraryBookEntry episodeEntry;
ILibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
@ -351,8 +357,7 @@ namespace LibationWinForms.GridView
return;
}
seriesEntry = new SeriesEntry(seriesBook, episodeBook);
seriesEntry = new SeriesEntry<WinFormsEntryStatus>(seriesBook, episodeBook);
seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0];
@ -362,10 +367,10 @@ namespace LibationWinForms.GridView
else
{
//Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new(episodeBook) { Parent = seriesEntry };
episodeEntry = new LibraryBookEntry<WinFormsEntryStatus>(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateSeries(seriesBook);
seriesEntry.UpdateLibraryBook(seriesBook);
}
//Add episode to the grid beneath the parent
@ -376,9 +381,6 @@ namespace LibationWinForms.GridView
bindingList.ExpandItem(seriesEntry);
else
bindingList.CollapseItem(seriesEntry);
seriesEntry.NotifyPropertyChanged();
}
else
existingEpisodeEntry.UpdateLibraryBook(episodeBook);
@ -465,10 +467,10 @@ namespace LibationWinForms.GridView
//Remove column is always first;
removeGVColumn.DisplayIndex = 0;
removeGVColumn.Visible = false;
removeGVColumn.ValueType = typeof(RemoveStatus);
removeGVColumn.FalseValue = RemoveStatus.NotRemoved;
removeGVColumn.TrueValue = RemoveStatus.Removed;
removeGVColumn.IndeterminateValue = RemoveStatus.SomeRemoved;
removeGVColumn.ValueType = typeof(bool?);
removeGVColumn.FalseValue = false;
removeGVColumn.TrueValue = true;
removeGVColumn.IndeterminateValue = null;
}
private void HideMenuItem_Click(object sender, EventArgs e)

View File

@ -1,44 +0,0 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationWinForms.GridView
{
#nullable enable
internal static class QueryExtensions
{
public static IEnumerable<LibraryBookEntry> BookEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;
try
{
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return gridEntries.SeriesEntries().FirstOrDefault(
lb =>
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId));
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
return null;
}
}
}
#nullable disable
}

View File

@ -1,133 +0,0 @@
using DataLayer;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationWinForms.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry : GridEntry
{
[Browsable(false)] public List<LibraryBookEntry> Children { get; }
[Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded);
[Browsable(false)] public override bool IsSeries => true;
[Browsable(false)] public override bool IsEpisode => false;
[Browsable(false)] public override bool IsBook => false;
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove is RemoveStatus.Removed);
if (removeCount == 0)
_remove = RemoveStatus.NotRemoved;
else if (removeCount == Children.Count)
_remove = RemoveStatus.Removed;
else
_remove = RemoveStatus.SomeRemoved;
NotifyPropertyChanged(nameof(Remove));
}
#region Model properties exposed to the view
public override RemoveStatus Remove
{
get
{
return _remove;
}
set
{
_remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
NotifyPropertyChanged();
}
}
public override LiberateButtonStatus Liberate { get; }
public override string DisplayTags { get; } = string.Empty;
#endregion
private SeriesEntry(LibraryBook parent)
{
Liberate = new LiberateButtonStatus(isSeries: true, isAbsent: false);
SeriesIndex = -1;
LibraryBook = parent;
LoadCover();
}
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children) : this(parent)
{
Children = children
.Select(c => new LibraryBookEntry(c) { Parent = this })
.OrderBy(c => c.SeriesIndex)
.ToList();
UpdateSeries(parent);
}
public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent)
{
Children = new() { new LibraryBookEntry(child) { Parent = this } };
UpdateSeries(parent);
}
public void UpdateSeries(LibraryBook parent)
{
LibraryBook = parent;
Title = Book.Title;
Series = Book.SeriesNames();
_myRating = Book.UserDefinedItem.Rating;
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(LibraryBook);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
NotifyPropertyChanged();
}
#region Data Sorting
/// <summary>Create getters for all member object values by name</summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Remove), () => Remove },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
{ nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) },
{ nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(DisplayTags), () => string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
#endregion
}
}

View File

@ -0,0 +1,37 @@
using DataLayer;
using Dinah.Core.WindowsDesktop.Drawing;
using LibationUiBase.GridView;
using System;
using System.Drawing;
namespace LibationWinForms.GridView
{
public class WinFormsEntryStatus : EntryStatus, IEntryStatus
{
private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230);
public override object BackgroundBrush => IsEpisode ? SERIES_BG_COLOR : SystemColors.ControlLightLight;
private WinFormsEntryStatus(LibraryBook libraryBook) : base(libraryBook) { }
public static EntryStatus Create(LibraryBook libraryBook) => new WinFormsEntryStatus(libraryBook);
protected override object LoadImage(byte[] picture)
{
try
{
return ImageReader.ToImage(picture);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book);
return Properties.Resources.default_cover_80x80;
}
}
protected override Image GetResourceImage(string rescName)
{
var image = Properties.Resources.ResourceManager.GetObject(rescName);
return image as Bitmap;
}
}
}