diff --git a/Source/LibationWinForms/AvaloniaUI/App.axaml b/Source/LibationWinForms/AvaloniaUI/App.axaml
new file mode 100644
index 00000000..dadc8c76
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/App.axaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Source/LibationWinForms/AvaloniaUI/App.axaml.cs b/Source/LibationWinForms/AvaloniaUI/App.axaml.cs
new file mode 100644
index 00000000..44aab666
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/App.axaml.cs
@@ -0,0 +1,28 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using LibationWinForms.AvaloniaUI.Views;
+
+namespace LibationWinForms.AvaloniaUI
+{
+ public class App : Application
+ {
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow
+ {
+
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/cancel.png b/Source/LibationWinForms/AvaloniaUI/Assets/cancel.png
new file mode 100644
index 00000000..fa34f935
Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/cancel.png differ
diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/completed.png b/Source/LibationWinForms/AvaloniaUI/Assets/completed.png
new file mode 100644
index 00000000..3cd61981
Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/completed.png differ
diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/down.png b/Source/LibationWinForms/AvaloniaUI/Assets/down.png
new file mode 100644
index 00000000..2536c961
Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/down.png differ
diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/errored.png b/Source/LibationWinForms/AvaloniaUI/Assets/errored.png
new file mode 100644
index 00000000..bb8ba7ef
Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/errored.png differ
diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/first.png b/Source/LibationWinForms/AvaloniaUI/Assets/first.png
new file mode 100644
index 00000000..e470c697
Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/first.png differ
diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/import_16x16.png b/Source/LibationWinForms/AvaloniaUI/Assets/import_16x16.png
new file mode 100644
index 00000000..40b582b1
Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/import_16x16.png differ
diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/last.png b/Source/LibationWinForms/AvaloniaUI/Assets/last.png
new file mode 100644
index 00000000..3c3ea886
Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/last.png differ
diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/queued.png b/Source/LibationWinForms/AvaloniaUI/Assets/queued.png
new file mode 100644
index 00000000..f30221c3
Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/queued.png differ
diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/up.png b/Source/LibationWinForms/AvaloniaUI/Assets/up.png
new file mode 100644
index 00000000..7c00155a
Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/up.png differ
diff --git a/Source/LibationWinForms/AvaloniaUI/AsyncNotifyPropertyChanged2.cs b/Source/LibationWinForms/AvaloniaUI/AsyncNotifyPropertyChanged2.cs
new file mode 100644
index 00000000..6913fe0d
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/AsyncNotifyPropertyChanged2.cs
@@ -0,0 +1,14 @@
+using Dinah.Core.Threading;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace LibationWinForms.AvaloniaUI
+{
+ public abstract class AsyncNotifyPropertyChanged2 : INotifyPropertyChanged
+ {
+ // see also notes in Libation/Source/_ARCHITECTURE NOTES.txt :: MVVM
+ public event PropertyChangedEventHandler PropertyChanged;
+ public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
+ => Avalonia.Threading.Dispatcher.UIThread.Post(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml
new file mode 100644
index 00000000..d67603fe
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs
new file mode 100644
index 00000000..f70a91c0
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs
@@ -0,0 +1,32 @@
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Interactivity;
+using Avalonia.Styling;
+using LibationWinForms.AvaloniaUI.ViewModels;
+using System;
+
+namespace LibationWinForms.AvaloniaUI.Controls
+{
+ public partial class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn
+ {
+ protected override object PrepareCellForEdit(IControl editingElement, RoutedEventArgs editingEventArgs)
+ {
+ return base.PrepareCellForEdit(editingElement, editingEventArgs);
+ }
+ protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem)
+ {
+ var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
+ ele.Checked += EditingElement_Checked;
+ return ele;
+ }
+
+ private void EditingElement_Checked(object sender, RoutedEventArgs e)
+ {
+ var cbox = sender as CheckBox;
+ var gEntry = cbox.DataContext as GridEntry2;
+ gEntry.Remove = cbox.IsChecked;
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml
new file mode 100644
index 00000000..6607fa93
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml
@@ -0,0 +1,5 @@
+
diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml.cs
new file mode 100644
index 00000000..485f3a7b
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml.cs
@@ -0,0 +1,29 @@
+using Avalonia.Controls;
+using Avalonia.Styling;
+using System;
+
+namespace LibationWinForms.AvaloniaUI.Controls
+{
+ public partial class FormattableMenuItem : MenuItem, IStyleable
+ {
+ Type IStyleable.StyleKey => typeof(MenuItem);
+
+ private string _formatText;
+ public string FormatText
+ {
+ get => _formatText;
+ set
+ {
+ _formatText = value;
+ Header = value;
+ }
+ }
+
+ public string Format(params object[] args)
+ {
+ var formatText = string.Format(FormatText, args);
+ Header = formatText;
+ return formatText;
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml
new file mode 100644
index 00000000..cffbf31e
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml.cs
new file mode 100644
index 00000000..38834f20
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml.cs
@@ -0,0 +1,27 @@
+using Avalonia.Controls;
+using Avalonia.Styling;
+using System;
+
+namespace LibationWinForms.AvaloniaUI.Controls
+{
+ public partial class FormattableTextBlock : TextBlock, IStyleable
+ {
+ Type IStyleable.StyleKey => typeof(TextBlock);
+
+ private string _formatText;
+ public string FormatText
+ {
+ get => _formatText;
+ set
+ {
+ _formatText = value;
+ Text = value;
+ }
+ }
+
+ public string Format(params object[] args)
+ {
+ return Text = string.Format(FormatText, args);
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewLocator.cs b/Source/LibationWinForms/AvaloniaUI/ViewLocator.cs
new file mode 100644
index 00000000..a17b206c
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewLocator.cs
@@ -0,0 +1,30 @@
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using LibationWinForms.AvaloniaUI.ViewModels;
+using System;
+
+namespace LibationWinForms.AvaloniaUI
+{
+ public class ViewLocator : IDataTemplate
+ {
+ public IControl Build(object data)
+ {
+ var name = data.GetType().FullName!.Replace("ViewModel", "View");
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+ else
+ {
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+ }
+
+ public bool Match(object data)
+ {
+ return data is ViewModelBase;
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs
new file mode 100644
index 00000000..33d89e2d
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs
@@ -0,0 +1,52 @@
+using Avalonia.Controls;
+using Avalonia.Media.Imaging;
+using System;
+using System.ComponentModel;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public class BookTags
+ {
+ private static Bitmap _buttonImage;
+
+ static BookTags()
+ {
+ var memoryStream = new System.IO.MemoryStream();
+
+ Properties.Resources.edit_25x25.Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png);
+ memoryStream.Position = 0;
+ _buttonImage = new Bitmap(memoryStream);
+
+ }
+
+ public string Tags { get; init; }
+ public bool IsSeries { get; init; }
+
+ public Control Control
+ {
+ get
+ {
+ if (IsSeries)
+ return null;
+
+ if (string.IsNullOrEmpty(Tags))
+ {
+ return new Image
+ {
+ Stretch = Avalonia.Media.Stretch.None,
+ Source = _buttonImage
+ };
+ }
+ else
+ {
+ return new TextBlock
+ {
+ Text = Tags,
+ Margin = new Avalonia.Thickness(0, 0),
+ TextWrapping = Avalonia.Media.TextWrapping.WrapWithOverflow
+ };
+ }
+ }
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs
new file mode 100644
index 00000000..afd46f72
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs
@@ -0,0 +1,171 @@
+using DataLayer;
+using Dinah.Core;
+using Dinah.Core.DataBinding;
+using Dinah.Core.Drawing;
+using LibationFileManager;
+using LibationWinForms.GridView;
+using ReactiveUI;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Drawing;
+using System.Linq;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public enum RemoveStatus
+ {
+ NotRemoved,
+ Removed,
+ SomeRemoved
+ }
+ /// The View Model base for the DataGridView
+ public abstract class GridEntry2 : AsyncNotifyPropertyChanged2, 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)] protected Book Book => LibraryBook.Book;
+
+ #region Model properties exposed to the view
+
+ protected bool? _remove = false;
+ public abstract bool? Remove { get; set; }
+
+ public abstract LiberateButtonStatus2 Liberate { get; }
+ public Avalonia.Media.Imaging.Bitmap 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; set; }
+ public string Narrators { get; protected set; }
+ public string Category { get; protected set; }
+ public string Misc { get; protected set; }
+ public string Description { get; protected set; }
+ public string ProductRating { get; protected set; }
+ public string MyRating { get; protected set; }
+ public abstract BookTags BookTags { get; }
+
+ #endregion
+
+ #region Sorting
+
+ public GridEntry2() => _memberValues = CreateMemberValueDictionary();
+
+ // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
+ // Used by GridEntryBindingList for all sorting
+ public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
+ public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
+ protected abstract Dictionary> CreateMemberValueDictionary();
+ private Dictionary> _memberValues { get; set; }
+
+ // Instantiate comparers for every exposed member object type.
+ private static readonly Dictionary _memberTypeComparers = new()
+ {
+ { typeof(RemoveStatus), new ObjectComparer() },
+ { typeof(string), new ObjectComparer() },
+ { typeof(int), new ObjectComparer() },
+ { typeof(float), new ObjectComparer() },
+ { typeof(bool), new ObjectComparer() },
+ { typeof(DateTime), new ObjectComparer() },
+ { typeof(LiberateButtonStatus2), new ObjectComparer() },
+ };
+
+ #endregion
+
+ #region Cover Art
+
+ private Avalonia.Media.Imaging.Bitmap _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.
+ using var ms = new System.IO.MemoryStream(picture);
+ _cover = new Avalonia.Media.Imaging.Bitmap(ms);
+ }
+
+ private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
+ {
+ if (e.Definition.PictureId == Book.PictureId)
+ {
+ using var ms = new System.IO.MemoryStream(e.Picture);
+ Cover = new Avalonia.Media.Imaging.Bitmap(ms);
+ PictureStorage.PictureCached -= PictureStorage_PictureCached;
+ }
+ }
+
+ #endregion
+
+ #region Static library display functions
+
+ /// This information should not change during lifetime, so call only once.
+ protected static string GetDescriptionDisplay(Book book)
+ {
+ var doc = new HtmlAgilityPack.HtmlDocument();
+ doc.LoadHtml(book?.Description?.Replace("
", "\r\n\r\n") ?? "");
+ return doc.DocumentNode.InnerText.Trim();
+ }
+
+ protected static string TrimTextToWord(string text, int maxLength)
+ {
+ return
+ text.Length <= maxLength ?
+ text :
+ text.Substring(0, maxLength - 3) + "...";
+ }
+
+
+ ///
+ /// This information should not change during lifetime, so call only once.
+ /// Maximum of 5 text rows will fit in 80-pixel row height.
+ ///
+ protected static string GetMiscDisplay(LibraryBook libraryBook)
+ {
+ var details = new List();
+
+ var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]");
+ var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]");
+
+ details.Add($"Account: {locale} - {acct}");
+
+ if (libraryBook.Book.HasPdf())
+ details.Add("Has PDF");
+ if (libraryBook.Book.IsAbridged)
+ details.Add("Abridged");
+ if (libraryBook.Book.DatePublished.HasValue)
+ details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}");
+ // this goes last since it's most likely to have a line-break
+ if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher))
+ details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}");
+
+ if (!details.Any())
+ return "[details not imported]";
+
+ return string.Join("\r\n", details);
+ }
+
+ #endregion
+
+ ~GridEntry2()
+ {
+ PictureStorage.PictureCached -= PictureStorage_PictureCached;
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs
new file mode 100644
index 00000000..1de83350
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs
@@ -0,0 +1,233 @@
+using ApplicationServices;
+using Dinah.Core.DataBinding;
+using LibationSearchEngine;
+using LibationWinForms.GridView;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Linq;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ /*
+ * Allows filtering and sorting of the underlying BindingList
+ * by implementing IBindingListView and using SearchEngineCommands
+ *
+ * When filtering is applied, the filtered-out items are removed
+ * from the base list and added to the private FilterRemoved list.
+ * When filtering is removed, items in the FilterRemoved list are
+ * added back to the base list.
+ *
+ * Remove is overridden to ensure that removed items are removed from
+ * the base list (visible items) as well as the FilterRemoved list.
+ */
+ public class GridEntryBindingList2 : ObservableCollection
+ {
+ public GridEntryBindingList2(IEnumerable enumeration) : base(new List(enumeration))
+ {
+ foreach (var item in enumeration)
+ item.PropertyChanged += Item_PropertyChanged;
+ }
+ /// All items in the list, including those filtered out.
+ public List AllItems() => Items.Concat(FilterRemoved).ToList();
+
+ /// When true, itms will not be checked filtered by search criteria on item changed
+ public bool SuspendFilteringOnUpdate { get; set; }
+ public string Filter { get => FilterString; set => ApplyFilter(value); }
+ protected MemberComparer Comparer { get; } = new();
+
+ /// Items that were removed from the base list due to filtering
+ private readonly List FilterRemoved = new();
+ private string FilterString;
+ private SearchResultSet SearchResults;
+ private bool isSorted;
+
+ #region Items Management
+
+ public new void Remove(GridEntry2 entry)
+ {
+ entry.PropertyChanged -= Item_PropertyChanged;
+ FilterRemoved.Add(entry);
+ base.Remove(entry);
+ }
+
+ protected override void RemoveItem(int index)
+ {
+ var item = Items[index];
+ item.PropertyChanged -= Item_PropertyChanged;
+ base.RemoveItem(index);
+ }
+
+ protected override void ClearItems()
+ {
+ foreach (var item in Items)
+ item.PropertyChanged -= Item_PropertyChanged;
+ base.ClearItems();
+ }
+
+ protected override void InsertItem(int index, GridEntry2 item)
+ {
+ item.PropertyChanged += Item_PropertyChanged;
+ FilterRemoved.Remove(item);
+ base.InsertItem(index, item);
+ }
+
+ private void Item_PropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ //Don't audo-sort Remove column or else Avalonia will crash.
+ if (isSorted && e.PropertyName == Comparer.PropertyName && e.PropertyName != nameof(GridEntry.Remove))
+ {
+ Sort();
+
+ OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ return;
+ }
+ }
+
+ #endregion
+
+ #region Filtering
+
+ private void ApplyFilter(string filterString)
+ {
+ if (filterString != FilterString)
+ RemoveFilter();
+
+ FilterString = filterString;
+ SearchResults = SearchEngineCommands.Search(filterString);
+
+ var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry2)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());
+
+ var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList();
+
+ foreach (var item in filteredOut)
+ {
+ Remove(item);
+ }
+ }
+
+ public void RemoveFilter()
+ {
+ if (FilterString is null) return;
+
+ int visibleCount = Items.Count;
+
+ foreach (var item in FilterRemoved.ToList())
+ {
+ if (item is SeriesEntrys2 || item is LibraryBookEntry2 lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded))
+ {
+ InsertItem(visibleCount++, item);
+ }
+ }
+
+ if (isSorted)
+ Sort();
+ else
+ {
+ //No user sort is applied, so do default sorting by DateAdded, descending
+ Comparer.PropertyName = nameof(GridEntry.DateAdded);
+ Comparer.Direction = ListSortDirection.Descending;
+ Sort();
+ }
+
+ OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+
+ FilterString = null;
+ SearchResults = null;
+ }
+
+ #endregion
+
+ #region Expand/Collapse
+
+ public void CollapseAll()
+ {
+ foreach (var series in Items.SeriesEntries().ToList())
+ CollapseItem(series);
+ }
+
+ public void ExpandAll()
+ {
+ foreach (var series in Items.SeriesEntries().ToList())
+ ExpandItem(series);
+ }
+
+ public void CollapseItem(SeriesEntrys2 sEntry)
+ {
+ foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList())
+ {
+ Remove(episode);
+ }
+
+ sEntry.Liberate.Expanded = false;
+ }
+
+ public void ExpandItem(SeriesEntrys2 sEntry)
+ {
+ var sindex = Items.IndexOf(sEntry);
+
+ foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).ToList())
+ {
+ if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId))
+ {
+ InsertItem(++sindex, episode);
+ }
+ }
+ sEntry.Liberate.Expanded = true;
+ }
+
+ #endregion
+
+ #region Sorting
+
+ public void DoSortCore(string propertyName)
+ {
+ if (isSorted && Comparer.PropertyName == propertyName)
+ {
+ Comparer.Direction = ~Comparer.Direction & ListSortDirection.Descending;
+ }
+ else
+ {
+ Comparer.PropertyName = propertyName;
+ Comparer.Direction = ListSortDirection.Descending;
+ }
+
+ Sort();
+
+ isSorted = true;
+
+ OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
+ }
+
+ protected void Sort()
+ {
+ var itemsList = (List)Items;
+
+ var children = itemsList.BookEntries().Where(i => i.Parent is not null).ToList();
+
+ var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList();
+
+ itemsList.Clear();
+
+ //Only add parentless items at this stage. After these items are added in the
+ //correct sorting order, go back and add the children beneath their parents.
+ itemsList.AddRange(sortedItems);
+
+ foreach (var parent in children.Select(c => c.Parent).Distinct())
+ {
+ var pIndex = itemsList.IndexOf(parent);
+
+ //children should always be sorted by series index.
+ foreach (var c in children.Where(c => c.Parent == parent).OrderBy(c => c.SeriesIndex))
+ itemsList.Insert(++pIndex, c);
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ItemsRepeaterPageViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ItemsRepeaterPageViewModel.cs
new file mode 100644
index 00000000..22f14569
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ItemsRepeaterPageViewModel.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Collections.ObjectModel;
+using Avalonia.Media;
+using ReactiveUI;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public class ProcessQueueItems : ObservableCollection
+ {
+ public ProcessQueueItems(IEnumerable items) :base(items) { }
+
+ public void MoveFirst(ItemsRepeaterPageViewModel.Item item)
+ {
+ var index = Items.IndexOf(item);
+ if (index < 1) return;
+
+ Move(index, 0);
+ }
+ public void MoveUp(ItemsRepeaterPageViewModel.Item item)
+ {
+ var index = Items.IndexOf(item);
+ if (index < 1) return;
+
+ Move(index, index - 1);
+ }
+ public void MoveDown(ItemsRepeaterPageViewModel.Item item)
+ {
+ var index = Items.IndexOf(item);
+ if (index < 0 || index > Items.Count - 2) return;
+
+ Move(index, index + 1);
+ }
+
+ public void MoveLast(ItemsRepeaterPageViewModel.Item item)
+ {
+ var index = Items.IndexOf(item);
+ if (index < 0 || index > Items.Count - 2) return;
+
+ Move(index, Items.Count - 1);
+ }
+ }
+
+
+ public class ItemsRepeaterPageViewModel : ViewModelBase
+ {
+ private int _newItemIndex = 1;
+ private int _newGenerationIndex = 0;
+ private ProcessQueueItems _items;
+
+ public ItemsRepeaterPageViewModel()
+ {
+ _items = CreateItems();
+ }
+
+ public ProcessQueueItems Items
+ {
+ get => _items;
+ set => this.RaiseAndSetIfChanged(ref _items, value);
+ }
+
+ public Item? SelectedItem { get; set; }
+
+ public void AddItem()
+ {
+ var index = SelectedItem != null ? Items.IndexOf(SelectedItem) : -1;
+ Items.Insert(index + 1, new Item(index + 1, $"New Item {_newItemIndex++}"));
+ }
+
+ public void RemoveItem()
+ {
+ if (SelectedItem is not null)
+ {
+ Items.Remove(SelectedItem);
+ SelectedItem = null;
+ }
+ else if (Items.Count > 0)
+ {
+ Items.RemoveAt(Items.Count - 1);
+ }
+ }
+
+ public void RandomizeHeights()
+ {
+ var random = new Random();
+
+ foreach (var i in Items)
+ {
+ i.Height = random.Next(240) + 10;
+ }
+ }
+
+ public void ResetItems()
+ {
+ Items = CreateItems();
+ }
+
+ private ProcessQueueItems CreateItems()
+ {
+ var suffix = _newGenerationIndex == 0 ? string.Empty : $"[{_newGenerationIndex.ToString()}]";
+
+ _newGenerationIndex++;
+
+ return new ProcessQueueItems(
+ Enumerable.Range(1, 100).Select(i => new Item(i, $"Item {i.ToString()} {suffix}")));
+ }
+
+ public class Item : ViewModelBase
+ {
+ private double _height = double.NaN;
+ static Random rnd = new Random();
+
+ public Item(int index, string text)
+ {
+ Index = index;
+ Text = text;
+ Narrator = "Narrator " + index;
+ Author = "Author " + index;
+ Title = "Book " + index + ": This is a book title.\r\nThis is line 2 of the book title";
+
+ Progress = rnd.Next(0, 101);
+ ETA = "ETA: 01:14";
+
+ IsDownloading = rnd.Next(0, 2) == 0;
+
+ if (!IsDownloading)
+ IsFinished = rnd.Next(0, 2) == 0;
+
+ if (IsDownloading)
+ Title += "\r\nDOWNLOADING";
+ else if (IsFinished)
+ Title += "\r\nFINISHED";
+ else
+ Title += "\r\nQUEUED";
+ }
+
+ public bool IsFinished { get; }
+ public bool IsDownloading { get; }
+ public bool Queued => !IsFinished && !IsDownloading;
+
+
+ public int Index { get; }
+ public string Text { get; }
+ public string ETA { get; }
+ public string Narrator { get; }
+ public string Author { get; }
+ public string Title { get; }
+ public int Progress { get; }
+
+ public double Height
+ {
+ get => _height;
+ set => this.RaiseAndSetIfChanged(ref _height, value);
+ }
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs
new file mode 100644
index 00000000..5d044633
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs
@@ -0,0 +1,128 @@
+using Avalonia.Media.Imaging;
+using DataLayer;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public class LiberateButtonStatus2 : IComparable, INotifyPropertyChanged
+ {
+ public LiberatedStatus BookStatus { get; set; }
+ public LiberatedStatus? PdfStatus { get; set; }
+
+ private bool _expanded;
+ public bool Expanded
+ {
+ get => _expanded;
+ set
+ {
+ _expanded = value;
+ NotifyPropertyChanged();
+ NotifyPropertyChanged(nameof(Image));
+ NotifyPropertyChanged(nameof(ToolTip));
+ }
+ }
+ public bool IsSeries { get; init; }
+ public Bitmap Image => GetLiberateIcon();
+ public string ToolTip => GetTooltip();
+
+ static Dictionary images = new();
+
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ public void NotifyPropertyChanged([CallerMemberName] string propertyName = "") => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ ///
+ /// Defines the Liberate column's sorting behavior
+ ///
+ public int CompareTo(object obj)
+ {
+ if (obj is not LiberateButtonStatus2 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 (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 ? GetFromresc("minus") : GetFromresc("plus");
+
+ if (BookStatus == LiberatedStatus.Error)
+ return GetFromresc("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 GetFromresc($"liberate_{image_lib}{image_pdf}");
+ }
+ private string GetTooltip()
+ {
+ if (IsSeries)
+ return Expanded ? "Click to Collpase" : "Click to Expand";
+
+ 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 GetFromresc(string rescName)
+ {
+ if (images.ContainsKey(rescName)) return images[rescName];
+
+ var memoryStream = new System.IO.MemoryStream();
+
+ ((System.Drawing.Bitmap)Properties.Resources.ResourceManager.GetObject(rescName)).Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png);
+ memoryStream.Position = 0;
+ images[rescName] = new Bitmap(memoryStream);
+ return images[rescName];
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs
new file mode 100644
index 00000000..be7f5cf5
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs
@@ -0,0 +1,175 @@
+using ApplicationServices;
+using DataLayer;
+using Dinah.Core;
+using LibationWinForms.GridView;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ /// The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode
+ public class LibraryBookEntry2 : GridEntry2
+ {
+ [Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
+ [Browsable(false)] public SeriesEntrys2 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.HasValue ? value.Value : false;
+ Parent?.ChildRemoveUpdate();
+ NotifyPropertyChanged();
+ }
+ }
+
+ public override LiberateButtonStatus2 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 LiberateButtonStatus2 { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false };
+ }
+ }
+
+ public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) };
+
+ #endregion
+
+ public LibraryBookEntry2(LibraryBook libraryBook)
+ {
+ setLibraryBook(libraryBook);
+ LoadCover();
+ }
+
+ public void UpdateLibraryBook(LibraryBook libraryBook)
+ {
+ if (AudibleProductId != libraryBook.Book.AudibleProductId)
+ throw new Exception("Invalid grid entry update. IDs must match");
+
+ UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
+ setLibraryBook(libraryBook);
+ }
+
+ private void setLibraryBook(LibraryBook libraryBook)
+ {
+ LibraryBook = libraryBook;
+
+ Title = Book.Title;
+ Series = Book.SeriesNames();
+ Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min";
+ MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
+ PurchaseDate = libraryBook.DateAdded.ToString("d");
+ ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
+ Authors = Book.AuthorNames();
+ Narrators = Book.NarratorNames();
+ Category = string.Join(" > ", Book.CategoriesNames());
+ Misc = GetMiscDisplay(libraryBook);
+ LongDescription = GetDescriptionDisplay(Book);
+ Description = TrimTextToWord(LongDescription, 62);
+ SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
+
+ NotifyPropertyChanged(nameof(Title));
+ NotifyPropertyChanged(nameof(Series));
+ NotifyPropertyChanged(nameof(Length));
+ NotifyPropertyChanged(nameof(MyRating));
+ NotifyPropertyChanged(nameof(PurchaseDate));
+ NotifyPropertyChanged(nameof(ProductRating));
+ NotifyPropertyChanged(nameof(Authors));
+ NotifyPropertyChanged(nameof(Narrators));
+ NotifyPropertyChanged(nameof(Category));
+ NotifyPropertyChanged(nameof(Misc));
+ NotifyPropertyChanged(nameof(LongDescription));
+ NotifyPropertyChanged(nameof(Description));
+ NotifyPropertyChanged(nameof(SeriesIndex));
+
+ UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
+ }
+
+ #region detect changes to the model, update the view, and save to database.
+
+ ///
+ /// This event handler receives notifications from the model that it has changed.
+ /// Notify the view that it's changed.
+ ///
+ private void UserDefinedItem_ItemChanged(object sender, string itemName)
+ {
+ var udi = sender as UserDefinedItem;
+
+ if (udi.Book.AudibleProductId != Book.AudibleProductId)
+ return;
+
+ // UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view.
+ // - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs
+ // - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view.
+ switch (itemName)
+ {
+ case nameof(udi.Tags):
+ Book.UserDefinedItem.Tags = udi.Tags;
+ NotifyPropertyChanged(nameof(BookTags));
+ break;
+ case nameof(udi.BookStatus):
+ Book.UserDefinedItem.BookStatus = udi.BookStatus;
+ _bookStatus = udi.BookStatus;
+ NotifyPropertyChanged(nameof(Liberate));
+ break;
+ case nameof(udi.PdfStatus):
+ Book.UserDefinedItem.PdfStatus = udi.PdfStatus;
+ _pdfStatus = udi.PdfStatus;
+ NotifyPropertyChanged(nameof(Liberate));
+ break;
+ }
+ }
+
+ /// Save edits to the database
+ public void Commit(string newTags, LiberatedStatus bookStatus, LiberatedStatus? pdfStatus)
+ // MVVM pass-through
+ => Book.UpdateBook(newTags, bookStatus: bookStatus, pdfStatus: pdfStatus);
+
+ #endregion
+
+ #region Data Sorting
+
+ /// Create getters for all member object values by name
+ protected override Dictionary> CreateMemberValueDictionary() => new()
+ {
+ { nameof(Remove), () => Remove.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(BookTags), () => BookTags?.Tags ?? string.Empty },
+ { nameof(Liberate), () => Liberate },
+ { nameof(DateAdded), () => DateAdded },
+ };
+
+ #endregion
+
+ ~LibraryBookEntry2()
+ {
+ UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 00000000..8f281313
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,47 @@
+using ApplicationServices;
+using Avalonia.Collections;
+using DataLayer;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public class MainWindowViewModel : ViewModelBase
+ {
+ public string Greeting => "Welcome to Avalonia!";
+ public GridEntryBindingList2 People { get; set; }
+ public MainWindowViewModel(IEnumerable dbBooks)
+ {
+ var geList = dbBooks
+ .Where(lb => lb.Book.IsProduct())
+ .Select(b => new LibraryBookEntry2(b))
+ .Cast()
+ .ToList();
+
+ var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
+
+ var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
+
+ foreach (var parent in seriesBooks)
+ {
+ var seriesEpisodes = episodes.FindChildren(parent);
+
+ if (!seriesEpisodes.Any()) continue;
+
+ var seriesEntry = new SeriesEntrys2(parent, seriesEpisodes);
+
+ geList.Add(seriesEntry);
+ geList.AddRange(seriesEntry.Children);
+ }
+
+ People = new GridEntryBindingList2(geList.OrderByDescending(e => e.DateAdded));
+ People.CollapseAll();
+
+ }
+ }
+
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs
new file mode 100644
index 00000000..167523a3
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs
@@ -0,0 +1,385 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using ApplicationServices;
+using Avalonia.Media.Imaging;
+using DataLayer;
+using Dinah.Core;
+using FileLiberator;
+using LibationFileManager;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public enum ProcessBookResult
+ {
+ None,
+ Success,
+ Cancelled,
+ ValidationFail,
+ FailedRetry,
+ FailedSkip,
+ FailedAbort
+ }
+
+ public enum ProcessBookStatus
+ {
+ Queued,
+ Cancelled,
+ Working,
+ Completed,
+ Failed
+ }
+
+ ///
+ /// This is the viewmodel for queued processables
+ ///
+ public class ProcessBook2 : INotifyPropertyChanged
+ {
+ public event EventHandler Completed;
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ public LibraryBook LibraryBook { get; private set; }
+
+ private ProcessBookResult _result = ProcessBookResult.None;
+ private ProcessBookStatus _status = ProcessBookStatus.Queued;
+ private string _narrator;
+ private string _author;
+ private string _title;
+ private int _progress;
+ private string _eta;
+ private Bitmap _cover;
+
+ #region Properties exposed to the view
+ public ProcessBookResult Result { get => _result; private set { _result = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(StatusText)); } }
+ public ProcessBookStatus Status { get => _status; private set { _status = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(BackgroundColor)); NotifyPropertyChanged(nameof(IsFinished)); NotifyPropertyChanged(nameof(IsDownloading)); NotifyPropertyChanged(nameof(Queued)); } }
+ public string Narrator { get => _narrator; set { _narrator = value; NotifyPropertyChanged(); } }
+ public string Author { get => _author; set { _author = value; NotifyPropertyChanged(); } }
+ public string Title { get => _title; set { _title = value; NotifyPropertyChanged(); } }
+ public int Progress { get => _progress; private set { _progress = value; NotifyPropertyChanged(); } }
+ public string ETA { get => _eta; private set { _eta = value; NotifyPropertyChanged(); } }
+ public Bitmap Cover { get => _cover; private set { _cover = value; NotifyPropertyChanged(); } }
+ public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working;
+ public bool IsDownloading => Status is ProcessBookStatus.Working;
+ public bool Queued => Status is ProcessBookStatus.Queued;
+
+ public string BackgroundColor => Status switch
+ {
+ ProcessBookStatus.Cancelled => "Khaki",
+ ProcessBookStatus.Completed => "PaleGreen",
+ ProcessBookStatus.Failed => "LightCoral",
+ _ => string.Empty,
+ };
+ public string StatusText => Result switch
+ {
+ ProcessBookResult.Success => "Finished",
+ ProcessBookResult.Cancelled => "Cancelled",
+ ProcessBookResult.ValidationFail => "Validion fail",
+ ProcessBookResult.FailedRetry => "Error, will retry later",
+ ProcessBookResult.FailedSkip => "Error, Skippping",
+ ProcessBookResult.FailedAbort => "Error, Abort",
+ _ => Status.ToString(),
+ };
+
+ #endregion
+
+ private TimeSpan TimeRemaining { set { ETA = $"ETA: {value:mm\\:ss}"; } }
+ private Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke();
+ private Processable NextProcessable() => _currentProcessable = null;
+ private Processable _currentProcessable;
+ private readonly Queue> Processes = new();
+ private readonly ProcessQueue.LogMe Logger;
+ public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+
+ public ProcessBook2(LibraryBook libraryBook, ProcessQueue.LogMe logme)
+ {
+ LibraryBook = libraryBook;
+ Logger = logme;
+
+ _title = LibraryBook.Book.Title;
+ _author = LibraryBook.Book.AuthorNames();
+ _narrator = LibraryBook.Book.NarratorNames();
+
+ (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._80x80));
+
+ if (isDefault)
+ PictureStorage.PictureCached += PictureStorage_PictureCached;
+
+ // Mutable property. Set the field so PropertyChanged isn't fired.
+ using var ms = new System.IO.MemoryStream(picture);
+ _cover = new Bitmap(ms);
+ }
+
+ private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
+ {
+ if (e.Definition.PictureId == LibraryBook.Book.PictureId)
+ {
+ using var ms = new System.IO.MemoryStream(e.Picture);
+ Cover = new Bitmap(ms);
+ PictureStorage.PictureCached -= PictureStorage_PictureCached;
+ }
+ }
+
+ public async Task ProcessOneAsync()
+ {
+ string procName = CurrentProcessable.Name;
+ try
+ {
+ LinkProcessable(CurrentProcessable);
+
+ var statusHandler = await CurrentProcessable.ProcessSingleAsync(LibraryBook, validate: true);
+
+ if (statusHandler.IsSuccess)
+ return Result = ProcessBookResult.Success;
+ else if (statusHandler.Errors.Contains("Cancelled"))
+ {
+ Logger.Info($"{procName}: Process was cancelled {LibraryBook.Book}");
+ return Result = ProcessBookResult.Cancelled;
+ }
+ else if (statusHandler.Errors.Contains("Validation failed"))
+ {
+ Logger.Info($"{procName}: Validation failed {LibraryBook.Book}");
+ return Result = ProcessBookResult.ValidationFail;
+ }
+
+ foreach (var errorMessage in statusHandler.Errors)
+ Logger.Error($"{procName}: {errorMessage}");
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, procName);
+ }
+ finally
+ {
+ if (Result == ProcessBookResult.None)
+ Result = showRetry(LibraryBook);
+
+ Status = Result switch
+ {
+ ProcessBookResult.Success => ProcessBookStatus.Completed,
+ ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled,
+ _ => ProcessBookStatus.Failed,
+ };
+ }
+
+ return Result;
+ }
+
+ public async Task CancelAsync()
+ {
+ try
+ {
+ if (CurrentProcessable is AudioDecodable audioDecodable)
+ await audioDecodable.CancelAsync();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error(ex, $"{CurrentProcessable.Name}: Error while cancelling");
+ }
+ }
+
+ public void AddDownloadPdf() => AddProcessable();
+ public void AddDownloadDecryptBook() => AddProcessable();
+ public void AddConvertToMp3() => AddProcessable();
+
+ private void AddProcessable() where T : Processable, new()
+ {
+ Processes.Enqueue(() => new T());
+ }
+
+ public override string ToString() => LibraryBook.ToString();
+
+ #region Subscribers and Unsubscribers
+
+ private void LinkProcessable(Processable processable)
+ {
+ processable.Begin += Processable_Begin;
+ processable.Completed += Processable_Completed;
+ processable.StreamingProgressChanged += Streamable_StreamingProgressChanged;
+ processable.StreamingTimeRemaining += Streamable_StreamingTimeRemaining;
+
+ if (processable is AudioDecodable audioDecodable)
+ {
+ audioDecodable.RequestCoverArt += AudioDecodable_RequestCoverArt;
+ audioDecodable.TitleDiscovered += AudioDecodable_TitleDiscovered;
+ audioDecodable.AuthorsDiscovered += AudioDecodable_AuthorsDiscovered;
+ audioDecodable.NarratorsDiscovered += AudioDecodable_NarratorsDiscovered;
+ audioDecodable.CoverImageDiscovered += AudioDecodable_CoverImageDiscovered;
+ }
+ }
+
+ private void UnlinkProcessable(Processable processable)
+ {
+ processable.Begin -= Processable_Begin;
+ processable.Completed -= Processable_Completed;
+ processable.StreamingProgressChanged -= Streamable_StreamingProgressChanged;
+ processable.StreamingTimeRemaining -= Streamable_StreamingTimeRemaining;
+
+ if (processable is AudioDecodable audioDecodable)
+ {
+ audioDecodable.RequestCoverArt -= AudioDecodable_RequestCoverArt;
+ audioDecodable.TitleDiscovered -= AudioDecodable_TitleDiscovered;
+ audioDecodable.AuthorsDiscovered -= AudioDecodable_AuthorsDiscovered;
+ audioDecodable.NarratorsDiscovered -= AudioDecodable_NarratorsDiscovered;
+ audioDecodable.CoverImageDiscovered -= AudioDecodable_CoverImageDiscovered;
+ }
+ }
+
+ #endregion
+
+ #region AudioDecodable event handlers
+
+ private void AudioDecodable_TitleDiscovered(object sender, string title) => Title = title;
+
+ private void AudioDecodable_AuthorsDiscovered(object sender, string authors) => Author = authors;
+
+ private void AudioDecodable_NarratorsDiscovered(object sender, string narrators) => Narrator = narrators;
+
+
+ private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e)
+ {
+ byte[] coverData = PictureStorage
+ .GetPictureSynchronously(
+ new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500));
+
+ AudioDecodable_CoverImageDiscovered(this, coverData);
+ return coverData;
+ }
+
+ private void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt)
+ {
+ using var ms = new System.IO.MemoryStream(coverArt);
+ Cover = new Avalonia.Media.Imaging.Bitmap(ms);
+ }
+
+ #endregion
+
+ #region Streamable event handlers
+ private void Streamable_StreamingTimeRemaining(object sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining;
+
+
+ private void Streamable_StreamingProgressChanged(object sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress)
+ {
+ if (!downloadProgress.ProgressPercentage.HasValue)
+ return;
+
+ if (downloadProgress.ProgressPercentage == 0)
+ TimeRemaining = TimeSpan.Zero;
+ else
+ Progress = (int)downloadProgress.ProgressPercentage;
+ }
+
+ #endregion
+
+ #region Processable event handlers
+
+ private void Processable_Begin(object sender, LibraryBook libraryBook)
+ {
+ Status = ProcessBookStatus.Working;
+
+ Logger.Info($"{Environment.NewLine}{((Processable)sender).Name} Step, Begin: {libraryBook.Book}");
+
+ Title = libraryBook.Book.Title;
+ Author = libraryBook.Book.AuthorNames();
+ Narrator = libraryBook.Book.NarratorNames();
+ }
+
+ private async void Processable_Completed(object sender, LibraryBook libraryBook)
+ {
+ Logger.Info($"{((Processable)sender).Name} Step, Completed: {libraryBook.Book}");
+ UnlinkProcessable((Processable)sender);
+
+ if (Processes.Count > 0)
+ {
+ NextProcessable();
+ LinkProcessable(CurrentProcessable);
+ var result = await CurrentProcessable.ProcessSingleAsync(libraryBook, validate: true);
+
+ if (result.HasErrors)
+ {
+ foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed"))
+ Logger.Error(errorMessage);
+
+ Completed?.Invoke(this, EventArgs.Empty);
+ }
+ }
+ else
+ {
+ Completed?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ #endregion
+
+ #region Failure Handler
+
+ private ProcessBookResult showRetry(LibraryBook libraryBook)
+ {
+ Logger.Error("ERROR. All books have not been processed. Most recent book: processing failed");
+
+ System.Windows.Forms.DialogResult? dialogResult = Configuration.Instance.BadBook switch
+ {
+ Configuration.BadBookAction.Abort => System.Windows.Forms.DialogResult.Abort,
+ Configuration.BadBookAction.Retry => System.Windows.Forms.DialogResult.Retry,
+ Configuration.BadBookAction.Ignore => System.Windows.Forms.DialogResult.Ignore,
+ Configuration.BadBookAction.Ask => null,
+ _ => null
+ };
+
+ string details;
+ try
+ {
+ static string trunc(string str)
+ => string.IsNullOrWhiteSpace(str) ? "[empty]"
+ : (str.Length > 50) ? $"{str.Truncate(47)}..."
+ : str;
+
+ details =
+$@" Title: {libraryBook.Book.Title}
+ ID: {libraryBook.Book.AudibleProductId}
+ Author: {trunc(libraryBook.Book.AuthorNames())}
+ Narr: {trunc(libraryBook.Book.NarratorNames())}";
+ }
+ catch
+ {
+ details = "[Error retrieving details]";
+ }
+
+ // if null then ask user
+ dialogResult ??= System.Windows.Forms.MessageBox.Show(string.Format(SkipDialogText + "\r\n\r\nSee Settings to avoid this box in the future.", details), "Skip importing this book?", SkipDialogButtons, System.Windows.Forms.MessageBoxIcon.Question, SkipDialogDefaultButton);
+
+ if (dialogResult == System.Windows.Forms.DialogResult.Abort)
+ return ProcessBookResult.FailedAbort;
+
+ if (dialogResult == SkipResult)
+ {
+ libraryBook.Book.UpdateBookStatus(LiberatedStatus.Error);
+
+ Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}");
+
+ return ProcessBookResult.FailedSkip;
+ }
+
+ return ProcessBookResult.FailedRetry;
+ }
+
+ private string SkipDialogText => @"
+An error occurred while trying to process this book.
+{0}
+
+- ABORT: Stop processing books.
+
+- RETRY: retry this book later. Just skip it for now. Continue processing books. (Will try this book again later.)
+
+- IGNORE: Permanently ignore this book. Continue processing books. (Will not try this book again later.)
+".Trim();
+ private System.Windows.Forms.MessageBoxButtons SkipDialogButtons => System.Windows.Forms.MessageBoxButtons.AbortRetryIgnore;
+ private System.Windows.Forms.MessageBoxDefaultButton SkipDialogDefaultButton => System.Windows.Forms.MessageBoxDefaultButton.Button1;
+ private System.Windows.Forms.DialogResult SkipResult => System.Windows.Forms.DialogResult.Ignore;
+ }
+
+ #endregion
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs
new file mode 100644
index 00000000..049968e7
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs
@@ -0,0 +1,22 @@
+using ReactiveUI;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public class ProcessQueueViewModel : ViewModelBase
+ {
+ private TrackedQueue2 _items = new();
+ public ProcessQueueViewModel() { }
+ public TrackedQueue2 Items
+ {
+ get => _items;
+ set => this.RaiseAndSetIfChanged(ref _items, value);
+ }
+
+ public ProcessBook2 SelectedItem { get; set; }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs
new file mode 100644
index 00000000..a804b9db
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs
@@ -0,0 +1,46 @@
+using ApplicationServices;
+using Avalonia.Collections;
+using DataLayer;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public class ProductsDisplayViewModel : ViewModelBase
+ {
+ public string Greeting => "Welcome to Avalonia!";
+ public GridEntryBindingList2 People { get; set; }
+ public ProductsDisplayViewModel(IEnumerable dbBooks)
+ {
+ var geList = dbBooks
+ .Where(lb => lb.Book.IsProduct())
+ .Select(b => new LibraryBookEntry2(b))
+ .Cast()
+ .ToList();
+
+ var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
+
+ var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
+
+ foreach (var parent in seriesBooks)
+ {
+ var seriesEpisodes = episodes.FindChildren(parent);
+
+ if (!seriesEpisodes.Any()) continue;
+
+ var seriesEntry = new SeriesEntrys2(parent, seriesEpisodes);
+
+ geList.Add(seriesEntry);
+ geList.AddRange(seriesEntry.Children);
+ }
+
+ People = new GridEntryBindingList2(geList.OrderByDescending(e => e.DateAdded));
+ People.CollapseAll();
+ }
+ }
+
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs
new file mode 100644
index 00000000..df401514
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs
@@ -0,0 +1,44 @@
+using DataLayer;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+#nullable enable
+ internal static class QueryExtensions
+ {
+ public static IEnumerable BookEntries(this IEnumerable gridEntries)
+ => gridEntries.OfType();
+
+ public static IEnumerable SeriesEntries(this IEnumerable gridEntries)
+ => gridEntries.OfType();
+
+ public static T? FindByAsin(this IEnumerable gridEntries, string audibleProductID) where T : GridEntry2
+ => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
+
+ public static IEnumerable EmptySeries(this IEnumerable gridEntries)
+ => gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
+
+ public static SeriesEntrys2? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode)
+ {
+ if (seriesEpisode.Book.SeriesLink is null) return null;
+
+ try
+ {
+ //Parent books will always have exactly 1 SeriesBook due to how
+ //they are imported in ApiExtended.getChildEpisodesAsync()
+ return gridEntries.SeriesEntries().FirstOrDefault(
+ lb =>
+ seriesEpisode.Book.SeriesLink.Any(
+ s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId));
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
+ return null;
+ }
+ }
+ }
+#nullable disable
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs
new file mode 100644
index 00000000..8a268652
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs
@@ -0,0 +1,141 @@
+using DataLayer;
+using Dinah.Core;
+using LibationWinForms.GridView;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ /// The View Model for a LibraryBook that is ContentType.Parent
+ public class SeriesEntrys2 : GridEntry2
+ {
+ [Browsable(false)] public List Children { get; }
+ [Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded);
+
+ private bool suspendCounting = false;
+ public void ChildRemoveUpdate()
+ {
+ if (suspendCounting) return;
+
+ var removeCount = Children.Count(c => c.Remove == true);
+
+ if (removeCount == 0)
+ _remove = false;
+ else if (removeCount == Children.Count)
+ _remove = true;
+ else
+ _remove = null;
+ NotifyPropertyChanged(nameof(Remove));
+ }
+
+ #region Model properties exposed to the view
+ public override bool? Remove
+ {
+ get => _remove;
+ set
+ {
+ _remove = value.HasValue ? value : false;
+
+ suspendCounting = true;
+
+ foreach (var item in Children)
+ item.Remove = value;
+
+ suspendCounting = false;
+
+ NotifyPropertyChanged();
+ }
+ }
+
+ public override LiberateButtonStatus2 Liberate { get; }
+ public override BookTags BookTags { get; } = new() { IsSeries = true };
+
+ #endregion
+
+ private SeriesEntrys2(LibraryBook parent)
+ {
+ Liberate = new LiberateButtonStatus2 { IsSeries = true };
+ SeriesIndex = -1;
+ LibraryBook = parent;
+ LoadCover();
+ }
+
+ public SeriesEntrys2(LibraryBook parent, IEnumerable children) : this(parent)
+ {
+ Children = children
+ .Select(c => new LibraryBookEntry2(c) { Parent = this })
+ .OrderBy(c => c.SeriesIndex)
+ .ToList();
+ UpdateSeries(parent);
+ }
+
+ public SeriesEntrys2(LibraryBook parent, LibraryBook child) : this(parent)
+ {
+ Children = new() { new LibraryBookEntry2(child) { Parent = this } };
+ UpdateSeries(parent);
+ }
+
+ public void UpdateSeries(LibraryBook parent)
+ {
+ LibraryBook = parent;
+
+ Title = Book.Title;
+ Series = Book.SeriesNames();
+ MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
+ PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
+ ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
+ Authors = Book.AuthorNames();
+ Narrators = Book.NarratorNames();
+ 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(nameof(Title));
+ NotifyPropertyChanged(nameof(Series));
+ NotifyPropertyChanged(nameof(Length));
+ NotifyPropertyChanged(nameof(MyRating));
+ NotifyPropertyChanged(nameof(PurchaseDate));
+ NotifyPropertyChanged(nameof(ProductRating));
+ NotifyPropertyChanged(nameof(Authors));
+ NotifyPropertyChanged(nameof(Narrators));
+ NotifyPropertyChanged(nameof(Category));
+ NotifyPropertyChanged(nameof(Misc));
+ NotifyPropertyChanged(nameof(LongDescription));
+ NotifyPropertyChanged(nameof(Description));
+
+ NotifyPropertyChanged();
+ }
+
+ #region Data Sorting
+
+ /// Create getters for all member object values by name
+ protected override Dictionary> CreateMemberValueDictionary() => new()
+ {
+ { nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved },
+ { nameof(Title), () => Book.TitleSortable() },
+ { nameof(Series), () => Book.SeriesSortable() },
+ { nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) },
+ { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
+ { nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) },
+ { nameof(ProductRating), () => Book.Rating.FirstScore() },
+ { nameof(Authors), () => Authors },
+ { nameof(Narrators), () => Narrators },
+ { nameof(Description), () => Description },
+ { nameof(Category), () => Category },
+ { nameof(Misc), () => Misc },
+ { nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
+ { nameof(Liberate), () => Liberate },
+ { nameof(DateAdded), () => DateAdded },
+ };
+
+ #endregion
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue2[T].cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue2[T].cs
new file mode 100644
index 00000000..f44f0130
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue2[T].cs
@@ -0,0 +1,240 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public enum QueuePosition
+ {
+ Fisrt,
+ OneUp,
+ OneDown,
+ Last
+ }
+
+ /*
+ * This data structure is like lifting a metal chain one link at a time.
+ * Each time you grab and lift a new link (MoveNext call):
+ *
+ * 1) you're holding a new link in your hand (Current)
+ * 2) the remaining chain to be lifted shortens by 1 link (Queued)
+ * 3) the pile of chain at your feet grows by 1 link (Completed)
+ *
+ * The index is the link position from the first link you lifted to the
+ * last one in the chain.
+ *
+ *
+ * For this to work with Avalonia's ItemsRepeater, it must be an ObservableCollection
+ * (not merely a Collection with INotifyCollectionChanged, INotifyPropertyChanged).
+ * So TrackedQueue maintains 2 copies of the list. The primary copy of the list is
+ * split into Completed, Current and Queued and is used by ProcessQueue to keep track
+ * of what's what. The secondary copy is a concatenation of primary's three sources
+ * and is stored in ObservableCollection.Items. When the primary list changes, the
+ * secondary list is cleared and reset to match the primary.
+ */
+ public class TrackedQueue2 : ObservableCollection where T : class
+ {
+ public event EventHandler CompletedCountChanged;
+ public event EventHandler QueuededCountChanged;
+
+ public T Current { get; private set; }
+
+ public IReadOnlyList Queued => _queued;
+ public IReadOnlyList Completed => _completed;
+
+ private readonly List _queued = new();
+ private readonly List _completed = new();
+ private readonly object lockObject = new();
+
+ public bool RemoveQueued(T item)
+ {
+ bool itemsRemoved;
+ int queuedCount;
+
+ lock (lockObject)
+ {
+ itemsRemoved = _queued.Remove(item);
+ queuedCount = _queued.Count;
+ }
+
+ if (itemsRemoved)
+ {
+ QueuededCountChanged?.Invoke(this, queuedCount);
+ RebuildSecondary();
+ }
+ return itemsRemoved;
+ }
+
+ public void ClearCurrent()
+ {
+ lock(lockObject)
+ Current = null;
+ RebuildSecondary();
+ }
+
+ public bool RemoveCompleted(T item)
+ {
+ bool itemsRemoved;
+ int completedCount;
+
+ lock (lockObject)
+ {
+ itemsRemoved = _completed.Remove(item);
+ completedCount = _completed.Count;
+ }
+
+ if (itemsRemoved)
+ {
+ CompletedCountChanged?.Invoke(this, completedCount);
+ RebuildSecondary();
+ }
+ return itemsRemoved;
+ }
+
+ public void ClearQueue()
+ {
+ lock (lockObject)
+ _queued.Clear();
+ QueuededCountChanged?.Invoke(this, 0);
+ RebuildSecondary();
+ }
+
+ public void ClearCompleted()
+ {
+ lock (lockObject)
+ _completed.Clear();
+ CompletedCountChanged?.Invoke(this, 0);
+ RebuildSecondary();
+ }
+
+ public bool Any(Func predicate)
+ {
+ lock (lockObject)
+ {
+ return (Current != null && predicate(Current)) || _completed.Any(predicate) || _queued.Any(predicate);
+ }
+ }
+
+ public void MoveQueuePosition(T item, QueuePosition requestedPosition)
+ {
+ lock (lockObject)
+ {
+ if (_queued.Count == 0 || !_queued.Contains(item)) return;
+
+ if ((requestedPosition == QueuePosition.Fisrt || requestedPosition == QueuePosition.OneUp) && _queued[0] == item)
+ return;
+ if ((requestedPosition == QueuePosition.Last || requestedPosition == QueuePosition.OneDown) && _queued[^1] == item)
+ return;
+
+ int queueIndex = _queued.IndexOf(item);
+
+ if (requestedPosition == QueuePosition.OneUp)
+ {
+ _queued.RemoveAt(queueIndex);
+ _queued.Insert(queueIndex - 1, item);
+ }
+ else if (requestedPosition == QueuePosition.OneDown)
+ {
+ _queued.RemoveAt(queueIndex);
+ _queued.Insert(queueIndex + 1, item);
+ }
+ else if (requestedPosition == QueuePosition.Fisrt)
+ {
+ _queued.RemoveAt(queueIndex);
+ _queued.Insert(0, item);
+ }
+ else
+ {
+ _queued.RemoveAt(queueIndex);
+ _queued.Insert(_queued.Count, item);
+ }
+ }
+ RebuildSecondary();
+ }
+
+ public bool MoveNext()
+ {
+ int completedCount = 0, queuedCount = 0;
+ bool completedChanged = false;
+ try
+ {
+ lock (lockObject)
+ {
+ if (Current != null)
+ {
+ _completed.Add(Current);
+ completedCount = _completed.Count;
+ completedChanged = true;
+ }
+ if (_queued.Count == 0)
+ {
+ Current = null;
+ return false;
+ }
+ Current = _queued[0];
+ _queued.RemoveAt(0);
+
+ queuedCount = _queued.Count;
+ return true;
+ }
+ }
+ finally
+ {
+ if (completedChanged)
+ CompletedCountChanged?.Invoke(this, completedCount);
+ QueuededCountChanged?.Invoke(this, queuedCount);
+ RebuildSecondary();
+ }
+ }
+
+ public bool TryPeek(out T item)
+ {
+ lock (lockObject)
+ {
+ if (_queued.Count == 0)
+ {
+ item = null;
+ return false;
+ }
+ item = _queued[0];
+ return true;
+ }
+ }
+
+ public T Peek()
+ {
+ lock (lockObject)
+ {
+ if (_queued.Count == 0) throw new InvalidOperationException("Queue empty");
+ return _queued.Count > 0 ? _queued[0] : default;
+ }
+ }
+
+ public void Enqueue(IEnumerable item)
+ {
+ int queueCount;
+ lock (lockObject)
+ {
+ _queued.AddRange(item);
+ queueCount = _queued.Count;
+ }
+ foreach (var i in item)
+ base.Add(i);
+ QueuededCountChanged?.Invoke(this, queueCount);
+ }
+
+ private void RebuildSecondary()
+ {
+ base.ClearItems();
+ foreach (var item in GetAllItems())
+ base.Add(item);
+ }
+
+ public IEnumerable GetAllItems()
+ {
+ if (Current is null) return Completed.Concat(Queued);
+ return Completed.Concat(new List { Current }).Concat(Queued);
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs
new file mode 100644
index 00000000..29d0d3fc
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs
@@ -0,0 +1,11 @@
+using ReactiveUI;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace LibationWinForms.AvaloniaUI.ViewModels
+{
+ public class ViewModelBase : ReactiveObject
+ {
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.BackupCounts.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.BackupCounts.axaml.cs
new file mode 100644
index 00000000..0cfbad94
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.BackupCounts.axaml.cs
@@ -0,0 +1,121 @@
+using ApplicationServices;
+using Avalonia.Controls;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Avalonia.Threading;
+using Dinah.Core;
+
+namespace LibationWinForms.AvaloniaUI.Views
+{
+ public partial class MainWindow
+ {
+ private System.ComponentModel.BackgroundWorker updateCountsBw = new();
+ private void Configure_BackupCounts()
+ {
+ // init formattable
+ beginBookBackupsToolStripMenuItem.Format(0);
+ beginPdfBackupsToolStripMenuItem.Format(0);
+ pdfsCountsLbl.Text = "| [Calculating backed up PDFs]";
+
+ Opened += setBackupCounts;
+ LibraryCommands.LibrarySizeChanged += setBackupCounts;
+ LibraryCommands.BookUserDefinedItemCommitted += setBackupCounts;
+
+ updateCountsBw.DoWork += UpdateCountsBw_DoWork;
+ updateCountsBw.RunWorkerCompleted += exportMenuEnable;
+ updateCountsBw.RunWorkerCompleted += updateBottomBookNumbers;
+ updateCountsBw.RunWorkerCompleted += update_BeginBookBackups_menuItem;
+ updateCountsBw.RunWorkerCompleted += updateBottomPdfNumbersAsync;
+ updateCountsBw.RunWorkerCompleted += udpate_BeginPdfOnlyBackups_menuItem;
+ }
+ private bool runBackupCountsAgain;
+ private void setBackupCounts(object _, object __)
+ {
+ runBackupCountsAgain = true;
+
+ if (!updateCountsBw.IsBusy)
+ updateCountsBw.RunWorkerAsync();
+ }
+ private void UpdateCountsBw_DoWork(object sender, System.ComponentModel.DoWorkEventArgs e)
+ {
+ while (runBackupCountsAgain)
+ {
+ runBackupCountsAgain = false;
+ e.Result = LibraryCommands.GetCounts();
+ }
+ }
+
+ private void exportMenuEnable(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
+ {
+ var libraryStats = e.Result as LibraryCommands.LibraryStats;
+ Dispatcher.UIThread.Post(() => exportLibraryToolStripMenuItem.IsEnabled = libraryStats.HasBookResults);
+ }
+
+ // this cannot be cleanly be FormattableToolStripMenuItem because of the optional "Errors" text
+ private const string backupsCountsLbl_Format = "BACKUPS: No progress: {0} In process: {1} Fully backed up: {2}";
+
+ private void updateBottomBookNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
+ {
+ var libraryStats = e.Result as LibraryCommands.LibraryStats;
+
+ var formatString
+ = !libraryStats.HasBookResults ? "No books. Begin by importing your library"
+ : libraryStats.booksError > 0 ? backupsCountsLbl_Format + " Errors: {3}"
+ : libraryStats.HasPendingBooks ? backupsCountsLbl_Format
+ : $"All {"book".PluralizeWithCount(libraryStats.booksFullyBackedUp)} backed up";
+ var statusStripText = string.Format(formatString,
+ libraryStats.booksNoProgress,
+ libraryStats.booksDownloadedOnly,
+ libraryStats.booksFullyBackedUp,
+ libraryStats.booksError);
+ Dispatcher.UIThread.InvokeAsync(() => backupsCountsLbl.Text = statusStripText);
+ }
+
+ // update 'begin book backups' menu item
+ private void update_BeginBookBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
+ {
+ var libraryStats = e.Result as LibraryCommands.LibraryStats;
+
+ var menuItemText
+ = libraryStats.HasPendingBooks
+ ? $"{libraryStats.PendingBooks} remaining"
+ : "All books have been liberated";
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ beginBookBackupsToolStripMenuItem.Format(menuItemText);
+ beginBookBackupsToolStripMenuItem.IsEnabled = libraryStats.HasPendingBooks;
+ });
+ }
+
+ private async void updateBottomPdfNumbersAsync(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
+ {
+ var libraryStats = e.Result as LibraryCommands.LibraryStats;
+
+ // don't need to assign the output of Format(). It just makes this logic cleaner
+ var statusStripText
+ = !libraryStats.HasPdfResults ? ""
+ : libraryStats.pdfsNotDownloaded > 0 ? await Dispatcher.UIThread.InvokeAsync(()=> pdfsCountsLbl.Format(libraryStats.pdfsNotDownloaded, libraryStats.pdfsDownloaded))
+ : $" | All {libraryStats.pdfsDownloaded} PDFs downloaded";
+ await Dispatcher.UIThread.InvokeAsync(() => pdfsCountsLbl.Text = statusStripText);
+ }
+
+ // update 'begin pdf only backups' menu item
+ private void udpate_BeginPdfOnlyBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
+ {
+ var libraryStats = e.Result as LibraryCommands.LibraryStats;
+
+ var menuItemText
+ = libraryStats.pdfsNotDownloaded > 0
+ ? $"{libraryStats.pdfsNotDownloaded} remaining"
+ : "All PDFs have been downloaded";
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ beginPdfBackupsToolStripMenuItem.Format(menuItemText);
+ beginPdfBackupsToolStripMenuItem.IsEnabled = libraryStats.pdfsNotDownloaded > 0;
+ });
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs
new file mode 100644
index 00000000..be054276
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs
@@ -0,0 +1,52 @@
+using ApplicationServices;
+using Avalonia.Controls;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibationWinForms.AvaloniaUI.Views
+{
+ //DONE
+ public partial class MainWindow
+ {
+ private void Configure_Export() { }
+
+ public void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ try
+ {
+ var saveFileDialog = new System.Windows.Forms.SaveFileDialog
+ {
+ Title = "Where to export Library",
+ Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV files (*.csv)|*.csv|JSON files (*.json)|*.json" // + "|All files (*.*)|*.*"
+ };
+
+ if (saveFileDialog.ShowDialog() != System.Windows.Forms.DialogResult.OK)
+ return;
+
+ // FilterIndex is 1-based, NOT 0-based
+ switch (saveFileDialog.FilterIndex)
+ {
+ case 1: // xlsx
+ default:
+ LibraryExporter.ToXlsx(saveFileDialog.FileName);
+ break;
+ case 2: // csv
+ LibraryExporter.ToCsv(saveFileDialog.FileName);
+ break;
+ case 3: // json
+ LibraryExporter.ToJson(saveFileDialog.FileName);
+ break;
+ }
+
+ System.Windows.Forms.MessageBox.Show("Library exported to:\r\n" + saveFileDialog.FileName);
+ }
+ catch (Exception ex)
+ {
+ MessageBoxLib.ShowAdminAlert(null, "Error attempting to export your library.", "Error exporting", ex);
+ }
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs
new file mode 100644
index 00000000..e099d99b
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs
@@ -0,0 +1,53 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using LibationWinForms.Dialogs;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibationWinForms.AvaloniaUI.Views
+{
+ //DONE
+ public partial class MainWindow
+ {
+ protected void Configure_Filter() { }
+
+ public void filterHelpBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ => new SearchSyntaxDialog().ShowDialog();
+
+ public void filterSearchTb_KeyPress(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Return)
+ {
+ performFilter(this.filterSearchTb.Text);
+
+ // silence the 'ding'
+ e.Handled = true;
+ }
+ }
+
+ public void filterBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ => performFilter(this.filterSearchTb.Text);
+
+ private string lastGoodFilter = "";
+ private void performFilter(string filterString)
+ {
+ this.filterSearchTb.Text = filterString;
+
+ try
+ {
+ productsDisplay.Filter(filterString);
+ lastGoodFilter = filterString;
+ }
+ catch (Exception ex)
+ {
+ System.Windows.Forms.MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error);
+
+ // re-apply last good filter
+ performFilter(lastGoodFilter);
+ }
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs
new file mode 100644
index 00000000..c69c275b
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs
@@ -0,0 +1,63 @@
+using Avalonia.Controls;
+using DataLayer;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibationWinForms.AvaloniaUI.Views
+{
+ //DONE
+ public partial class MainWindow
+ {
+ private void Configure_Liberate() { }
+
+ //GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread
+ public void beginBookBackupsToolStripMenuItem_Click(object _ = null, Avalonia.Interactivity.RoutedEventArgs __ = null)
+ {
+ try
+ {
+ SetQueueCollapseState(false);
+
+ Serilog.Log.Logger.Information("Begin backing up all library books");
+
+ processBookQueue1.AddDownloadDecrypt(
+ ApplicationServices.DbContexts
+ .GetLibrary_Flat_NoTracking()
+ .UnLiberated()
+ );
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Error(ex, "An error occurred while backing up all library books");
+ }
+ }
+
+ public async void beginPdfBackupsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
+ {
+ SetQueueCollapseState(false);
+ await Task.Run(() => processBookQueue1.AddDownloadPdf(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()
+ .Where(lb => lb.Book.UserDefinedItem.PdfStatus is DataLayer.LiberatedStatus.NotLiberated)));
+ }
+
+ public async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
+ {
+ var result = System.Windows.Forms.MessageBox.Show(
+ "This converts all m4b titles in your library to mp3 files. Original files are not deleted."
+ + "\r\nFor large libraries this will take a long time and will take up more disk space."
+ + "\r\n\r\nContinue?"
+ + "\r\n\r\n(To always download titles as mp3 instead of m4b, go to Settings: Download my books as .MP3 files)",
+ "Convert all M4b => Mp3?",
+ System.Windows.Forms.MessageBoxButtons.YesNo,
+ System.Windows.Forms.MessageBoxIcon.Warning);
+ if (result == System.Windows.Forms.DialogResult.Yes)
+ {
+ SetQueueCollapseState(false);
+ await Task.Run(() => processBookQueue1.AddConvertMp3(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()
+ .Where(lb => lb.Book.UserDefinedItem.BookStatus is DataLayer.LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product)));
+ }
+ //Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing.
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs
new file mode 100644
index 00000000..132f39e2
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs
@@ -0,0 +1,66 @@
+using Avalonia.Controls;
+using DataLayer;
+using Dinah.Core;
+using LibationFileManager;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibationWinForms.AvaloniaUI.Views
+{
+ //DONE
+ public partial class MainWindow
+ {
+ private void Configure_ProcessQueue()
+ {
+ var collapseState = !Configuration.Instance.GetNonString(nameof(splitContainer1.IsPaneOpen));
+ SetQueueCollapseState(collapseState);
+ }
+
+ public void ProductsDisplay_LiberateClicked(object sender, LibraryBook libraryBook)
+ {
+ try
+ {
+ if (libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
+ {
+ Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", libraryBook);
+ SetQueueCollapseState(false);
+ processBookQueue1.AddDownloadDecrypt(libraryBook);
+ }
+ else if (libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
+ {
+ Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", libraryBook);
+ SetQueueCollapseState(false);
+ processBookQueue1.AddDownloadPdf(libraryBook);
+ }
+ else if (libraryBook.Book.Audio_Exists())
+ {
+ // liberated: open explorer to file
+ var filePath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
+ if (!Go.To.File(filePath?.ShortPathName))
+ {
+ var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
+ System.Windows.Forms.MessageBox.Show($"File not found" + suffix);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBook);
+ }
+ }
+ private void SetQueueCollapseState(bool collapsed)
+ {
+ splitContainer1.IsPaneOpen = !collapsed;
+ toggleQueueHideBtn.Content = splitContainer1.IsPaneOpen ? "❱❱❱" : "❰❰❰";
+ }
+
+ public void ToggleQueueHideBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ SetQueueCollapseState(splitContainer1.IsPaneOpen);
+ Configuration.Instance.SetObject(nameof(splitContainer1.IsPaneOpen), splitContainer1.IsPaneOpen);
+ }
+ }
+}
diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs
new file mode 100644
index 00000000..061a9f58
--- /dev/null
+++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs
@@ -0,0 +1,72 @@
+using Avalonia.Controls;
+using LibationFileManager;
+using LibationWinForms.Dialogs;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace LibationWinForms.AvaloniaUI.Views
+{
+ //DONE
+ public partial class MainWindow
+ {
+ private void Configure_QuickFilters()
+ {
+ Opened += updateFirstFilterIsDefaultToolStripMenuItem;
+ Opened += updateFiltersMenu;
+ QuickFilters.UseDefaultChanged += updateFirstFilterIsDefaultToolStripMenuItem;
+ QuickFilters.Updated += updateFiltersMenu;
+ }
+
+ private object quickFilterTag { get; } = new();
+ private void updateFiltersMenu(object _ = null, object __ = null)
+ {
+ var allItems = quickFiltersToolStripMenuItem
+ .Items
+ .Cast()
+ .ToList();
+
+ var toRemove = allItems
+ .OfType