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() + .Where(mi => mi.Tag == quickFilterTag) + .ToList(); + + allItems = allItems + .Except(toRemove) + .ToList(); + + // re-populate + var index = 0; + foreach (var filter in QuickFilters.Filters) + { + var quickFilterMenuItem = new MenuItem + { + Tag = quickFilterTag, + Header = $"_{++index}: {filter}" + }; + quickFilterMenuItem.Click += (_, __) => performFilter(filter); + allItems.Add(quickFilterMenuItem); + } + quickFiltersToolStripMenuItem.Items = allItems; + } + + private void updateFirstFilterIsDefaultToolStripMenuItem(object sender, EventArgs e) + => firstFilterIsDefaultToolStripMenuItem_Checkbox.IsChecked = QuickFilters.UseDefault; + + public void firstFilterIsDefaultToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => QuickFilters.UseDefault = firstFilterIsDefaultToolStripMenuItem_Checkbox.IsChecked != true; + + public void addQuickFilterBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => QuickFilters.Add(this.filterSearchTb.Text); + + public void editQuickFiltersToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => new EditQuickFilters().ShowDialog(); + + public void productsDisplay_Initialized(object sender, EventArgs e) + { + if (QuickFilters.UseDefault) + performFilter(QuickFilters.Filters.FirstOrDefault()); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs new file mode 100644 index 00000000..1043eb25 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs @@ -0,0 +1,101 @@ +using AudibleUtilities; +using Avalonia.Controls; +using LibationWinForms.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //WORKING + public partial class MainWindow + { + private void Configure_RemoveBooks() + { + removeBooksBtn.IsVisible = false; + doneRemovingBtn.IsVisible = false; + } + + public async void removeBooksBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + await productsDisplay.RemoveCheckedBooksAsync(); + } + + public void doneRemovingBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + removeBooksBtn.IsVisible = false; + doneRemovingBtn.IsVisible = false; + + productsDisplay.CloseRemoveBooksColumn(); + + //Restore the filter + filterSearchTb.IsEnabled = true; + filterSearchTb.IsVisible = true; + performFilter(filterSearchTb.Text); + } + + public void removeLibraryBooksToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + // if 0 accounts, this will not be visible + // if 1 account, run scanLibrariesRemovedBooks() on this account + // if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks() + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings.GetAll(); + + if (accounts.Count != 1) + return; + + var firstAccount = accounts.Single(); + scanLibrariesRemovedBooks(firstAccount); + } + + // selectively remove books from all accounts + public void removeAllAccountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var allAccounts = persister.AccountsSettings.GetAll(); + scanLibrariesRemovedBooks(allAccounts.ToArray()); + } + + // selectively remove books from some accounts + public void removeSomeAccountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var scanAccountsDialog = new ScanAccountsDialog(); + + if (scanAccountsDialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) + return; + + if (!scanAccountsDialog.CheckedAccounts.Any()) + return; + + scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray()); + } + + private async void scanLibrariesRemovedBooks(params Account[] accounts) + { + //This action is meant to operate on the entire library. + //For removing books within a filter set, use + //Visible Books > Remove from library + + filterSearchTb.IsEnabled = false; + filterSearchTb.IsVisible = false; + productsDisplay.Filter(null); + + removeBooksBtn.IsVisible = true; + doneRemovingBtn.IsVisible = true; + + await productsDisplay.ScanAndRemoveBooksAsync(accounts); + } + + public void productsDisplay_RemovableCountChanged(object sender, int removeCount) + { + removeBooksBtn.Content = removeCount switch + { + 1 => "Remove 1 Book from Libation", + _ => $"Remove {removeCount} Books from Libation" + }; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanAuto.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanAuto.axaml.cs new file mode 100644 index 00000000..0f3a0f3a --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanAuto.axaml.cs @@ -0,0 +1,93 @@ +using ApplicationServices; +using AudibleUtilities; +using Avalonia.Controls; +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 InterruptableTimer autoScanTimer; + + private void Configure_ScanAuto() + { + // creating InterruptableTimer inside 'Configure_' is a break from the pattern. As long as no one else needs to access or subscribe to it, this is ok + var hours = 0; + var minutes = 5; + var seconds = 0; + var _5_minutes = new TimeSpan(hours, minutes, seconds); + autoScanTimer = new InterruptableTimer(_5_minutes); + + // subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI + autoScanTimer.Elapsed += async (_, __) => + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings + .GetAll() + .Where(a => a.LibraryScan) + .ToArray(); + + // in autoScan, new books SHALL NOT show dialog + try + { + await LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.ApiExtendedFunc, accounts); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error invoking auto-scan"); + } + }; + + // load init state to menu checkbox + Opened += updateAutoScanLibraryToolStripMenuItem; + // if enabled: begin on load + Opened += startAutoScan; + + // if new 'default' account is added, run autoscan + AccountsSettingsPersister.Saving += accountsPreSave; + AccountsSettingsPersister.Saved += accountsPostSave; + + // when autoscan setting is changed, update menu checkbox and run autoscan + Configuration.Instance.AutoScanChanged += updateAutoScanLibraryToolStripMenuItem; + Configuration.Instance.AutoScanChanged += startAutoScan; + } + + private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts; + private List<(string AccountId, string LocaleName)> getDefaultAccounts() + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + return persister.AccountsSettings + .GetAll() + .Where(a => a.LibraryScan) + .Select(a => (a.AccountId, a.Locale.Name)) + .ToList(); + } + private void accountsPreSave(object sender = null, EventArgs e = null) + => preSaveDefaultAccounts = getDefaultAccounts(); + private void accountsPostSave(object sender = null, EventArgs e = null) + { + var postSaveDefaultAccounts = getDefaultAccounts(); + var newDefaultAccounts = postSaveDefaultAccounts.Except(preSaveDefaultAccounts).ToList(); + + if (newDefaultAccounts.Any()) + startAutoScan(); + } + + private void startAutoScan(object sender = null, EventArgs e = null) + { + if (Configuration.Instance.AutoScan) + autoScanTimer.PerformNow(); + else + autoScanTimer.Stop(); + } + private void updateAutoScanLibraryToolStripMenuItem(object sender, EventArgs e) => autoScanLibraryToolStripMenuItemCheckbox.IsChecked = Configuration.Instance.AutoScan; + private void autoScanLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) => Configuration.Instance.AutoScan = autoScanLibraryToolStripMenuItemCheckbox.IsChecked != true; + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs new file mode 100644 index 00000000..bf012a24 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs @@ -0,0 +1,94 @@ +using ApplicationServices; +using AudibleUtilities; +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_ScanManual() + { + Opened += refreshImportMenu; + AccountsSettingsPersister.Saved += refreshImportMenu; + } + + private void refreshImportMenu(object _, EventArgs __) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var count = persister.AccountsSettings.Accounts.Count; + + autoScanLibraryToolStripMenuItem.IsVisible = count > 0; + + noAccountsYetAddAccountToolStripMenuItem.IsVisible = count == 0; + scanLibraryToolStripMenuItem.IsVisible = count == 1; + scanLibraryOfAllAccountsToolStripMenuItem.IsVisible = count > 1; + scanLibraryOfSomeAccountsToolStripMenuItem.IsVisible = count > 1; + + removeLibraryBooksToolStripMenuItem.IsVisible = count > 0; + removeSomeAccountsToolStripMenuItem.IsVisible = count > 1; + removeAllAccountsToolStripMenuItem.IsVisible = count > 1; + } + + public void noAccountsYetAddAccountToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + System.Windows.Forms.MessageBox.Show("To load your Audible library, come back here to the Import menu after adding your account"); + new AccountsDialog().ShowDialog(); + } + + public async void scanLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var firstAccount = persister.AccountsSettings.GetAll().FirstOrDefault(); + await scanLibrariesAsync(firstAccount); + } + + public async void scanLibraryOfAllAccountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var allAccounts = persister.AccountsSettings.GetAll(); + await scanLibrariesAsync(allAccounts); + } + + public async void scanLibraryOfSomeAccountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var scanAccountsDialog = new ScanAccountsDialog(); + + if (scanAccountsDialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) + return; + + if (!scanAccountsDialog.CheckedAccounts.Any()) + return; + + await scanLibrariesAsync(scanAccountsDialog.CheckedAccounts); + } + + private async Task scanLibrariesAsync(IEnumerable accounts) => await scanLibrariesAsync(accounts.ToArray()); + private async Task scanLibrariesAsync(params Account[] accounts) + { + try + { + var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.ApiExtendedFunc, accounts); + + // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop + if (Configuration.Instance.ShowImportedStats && newAdded > 0) + System.Windows.Forms.MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}"); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + null, + "Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator", + "Error importing library", + ex); + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanNotification.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanNotification.axaml.cs new file mode 100644 index 00000000..a3594d47 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanNotification.axaml.cs @@ -0,0 +1,48 @@ +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_ScanNotification() + { + scanningToolStripMenuItem.IsVisible = false; + LibraryCommands.ScanBegin += LibraryCommands_ScanBegin; + LibraryCommands.ScanEnd += LibraryCommands_ScanEnd; + } + private void LibraryCommands_ScanBegin(object sender, int accountsLength) + { + removeLibraryBooksToolStripMenuItem.IsEnabled = false; + removeAllAccountsToolStripMenuItem.IsEnabled = false; + removeSomeAccountsToolStripMenuItem.IsEnabled = false; + scanLibraryToolStripMenuItem.IsEnabled = false; + scanLibraryOfAllAccountsToolStripMenuItem.IsEnabled = false; + scanLibraryOfSomeAccountsToolStripMenuItem.IsEnabled = false; + + this.scanningToolStripMenuItem.IsVisible = true; + this.scanningToolStripMenuItem_Text.Text + = (accountsLength == 1) + ? "Scanning..." + : $"Scanning {accountsLength} accounts..."; + } + + private void LibraryCommands_ScanEnd(object sender, EventArgs e) + { + removeLibraryBooksToolStripMenuItem.IsEnabled = true; + removeAllAccountsToolStripMenuItem.IsEnabled = true; + removeSomeAccountsToolStripMenuItem.IsEnabled = true; + scanLibraryToolStripMenuItem.IsEnabled = true; + scanLibraryOfAllAccountsToolStripMenuItem.IsEnabled = true; + scanLibraryOfSomeAccountsToolStripMenuItem.IsEnabled = true; + + this.scanningToolStripMenuItem.IsVisible = false; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs new file mode 100644 index 00000000..7957676e --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia.Controls; +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_Settings() { } + + public void accountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => new AccountsDialog().ShowDialog(); + + public void basicSettingsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => new SettingsDialog().ShowDialog(); + + public void aboutToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => System.Windows.Forms.MessageBox.Show($"Running Libation version {AppScaffolding.LibationScaffolding.BuildVersion}", $"Libation v{AppScaffolding.LibationScaffolding.BuildVersion}"); + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs new file mode 100644 index 00000000..4523ce71 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs @@ -0,0 +1,153 @@ +using ApplicationServices; +using Avalonia.Controls; +using Avalonia.Threading; +using DataLayer; +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_VisibleBooks() + { + // init formattable + visibleCountLbl.Format(0); + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(0); + liberateVisibleToolStripMenuItem_LiberateMenu.Format(0); + + // top menu strip + visibleBooksToolStripMenuItem.Format(0); + + LibraryCommands.BookUserDefinedItemCommitted += setLiberatedVisibleMenuItemAsync; + } + + private async void setLiberatedVisibleMenuItemAsync(object _, object __) + => await Task.Run(setLiberatedVisibleMenuItem); + + public void liberateVisible(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + try + { + SetQueueCollapseState(false); + + Serilog.Log.Logger.Information("Begin backing up visible library books"); + + processBookQueue1.AddDownloadDecrypt( + productsDisplay + .GetVisible() + .UnLiberated() + ); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error occurred while backing up visible library books"); + } + } + public void replaceTagsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var dialog = new TagsBatchDialog(); + var result = dialog.ShowDialog(); + if (result != System.Windows.Forms.DialogResult.OK) + return; + + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + $"Are you sure you want to replace tags in {0}?", + "Replace tags?"); + + if (confirmationResult != System.Windows.Forms.DialogResult.Yes) + return; + + foreach (var libraryBook in visibleLibraryBooks) + libraryBook.Book.UserDefinedItem.Tags = dialog.NewTags; + LibraryCommands.UpdateUserDefinedItem(visibleLibraryBooks.Select(lb => lb.Book)); + } + + public void setDownloadedToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var dialog = new LiberatedStatusBatchDialog(); + var result = dialog.ShowDialog(); + if (result != System.Windows.Forms.DialogResult.OK) + return; + + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + $"Are you sure you want to replace downloaded status in {0}?", + "Replace downloaded status?"); + + if (confirmationResult != System.Windows.Forms.DialogResult.Yes) + return; + + foreach (var libraryBook in visibleLibraryBooks) + libraryBook.Book.UserDefinedItem.BookStatus = dialog.BookLiberatedStatus; + LibraryCommands.UpdateUserDefinedItem(visibleLibraryBooks.Select(lb => lb.Book)); + } + + public async void removeToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + $"Are you sure you want to remove {0} from Libation's library?", + "Remove books from Libation?"); + + if (confirmationResult != System.Windows.Forms.DialogResult.Yes) + return; + + var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); + await LibraryCommands.RemoveBooksAsync(visibleIds); + } + public async void productsDisplay_VisibleCountChanged(object sender, int qty) + { + Dispatcher.UIThread.Post(() => + { + // bottom-left visible count + visibleCountLbl.Format(qty); + + // top menu strip + visibleBooksToolStripMenuItem.Format(qty); + visibleBooksToolStripMenuItem.IsEnabled = qty > 0; + }); + + //Not used for anything? + var notLiberatedCount = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); + + await Task.Run(setLiberatedVisibleMenuItem); + } + void setLiberatedVisibleMenuItem() + { + var notLiberated = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); + + Dispatcher.UIThread.Post(() => + { + if (notLiberated > 0) + { + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(notLiberated); + liberateVisibleToolStripMenuItem_VisibleBooksMenu.IsEnabled = true; + + liberateVisibleToolStripMenuItem_LiberateMenu.Format(notLiberated); + liberateVisibleToolStripMenuItem_LiberateMenu.IsEnabled = true; + } + else + { + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Header = "All visible books are liberated"; + liberateVisibleToolStripMenuItem_VisibleBooksMenu.IsEnabled = false; + + liberateVisibleToolStripMenuItem_LiberateMenu.Header = "All visible books are liberated"; + liberateVisibleToolStripMenuItem_LiberateMenu.IsEnabled = false; + } + }); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow._NoUI.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow._NoUI.axaml.cs new file mode 100644 index 00000000..6ec36db2 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow._NoUI.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia.Controls; +using Dinah.Core.Drawing; +using LibationFileManager; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public partial class MainWindow + { + private void Configure_NonUI() + { + // init default/placeholder cover art + var format = System.Drawing.Imaging.ImageFormat.Jpeg; + PictureStorage.SetDefaultImage(PictureSize._80x80, Properties.Resources.default_cover_80x80.ToBytes(format)); + PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format)); + PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format)); + PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format)); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml new file mode 100644 index 00000000..8cfe327b --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + Toggle _Me0 + + + + + + + + + + + + + + + + + + + + + + + + Toggle _Me0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl2.axaml.cs new file mode 100644 index 00000000..9519374d --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl2.axaml.cs @@ -0,0 +1,43 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using LibationWinForms.AvaloniaUI.ViewModels; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public enum QueueButton + { + Cancel, + MoveFirst, + MoveUp, + MoveDown, + MoveLast + } + public delegate void QueueItemButtonClicked(ProcessBook2 item, QueueButton queueButton); + public partial class ProcessBookControl2 : UserControl + { + public static event QueueItemButtonClicked ButtonClicked; + public ProcessBookControl2() + { + InitializeComponent(); + } + + private ProcessBook2 DataItem => DataContext is null ? null : DataContext as ProcessBook2; + + public void Cancel_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.Cancel); + public void MoveFirst_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.MoveFirst); + public void MoveUp_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.MoveUp); + public void MoveDown_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.MoveDown); + public void MoveLast_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.MoveLast); + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml new file mode 100644 index 00000000..f4cd64fe --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + Process Queue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 00:00:25 + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs new file mode 100644 index 00000000..9760cfc4 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs @@ -0,0 +1,326 @@ +using ApplicationServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public partial class ProcessQueueControl2 : UserControl, ProcessQueue.ILogForm + { + private readonly ProcessQueueViewModel _viewModel; + private ItemsRepeater _repeater; + private ScrollViewer _scroller; + private int _selectedIndex; + private Random _random = new Random(0); + + + private TrackedQueue2 Queue => _viewModel.Items; + + private readonly ProcessQueue.LogMe Logger; + private int QueuedCount + { + set + { + queueNumberLbl_Text.Text = value.ToString(); + queueNumberLbl_Text.IsVisible = value > 0; + queueNumberLbl_Icon.IsVisible = value > 0; + } + } + private int ErrorCount + { + set + { + errorNumberLbl_Text.Text = value.ToString(); + errorNumberLbl_Text.IsVisible = value > 0; + errorNumberLbl_Icon.IsVisible = value > 0; + } + } + + private int CompletedCount + { + set + { + completedNumberLbl_Text.Text = value.ToString(); + completedNumberLbl_Text.IsVisible = value > 0; + completedNumberLbl_Icon.IsVisible = value > 0; + } + } + + public Task QueueRunner { get; private set; } + public bool Running => !QueueRunner?.IsCompleted ?? false; + + public ProcessQueueControl2() + { + InitializeComponent(); + _repeater = this.Get("repeater"); + _scroller = this.Get("scroller"); + _repeater.PointerPressed += RepeaterClick; + _repeater.KeyDown += RepeaterOnKeyDown; + DataContext = _viewModel = new ProcessQueueViewModel(); + + ProcessBookControl2.ButtonClicked += ProcessBookControl2_ButtonClicked; + + queueNumberLbl_Icon = this.FindControl(nameof(queueNumberLbl_Icon)); + errorNumberLbl_Icon = this.FindControl(nameof(errorNumberLbl_Icon)); + completedNumberLbl_Icon = this.FindControl(nameof(completedNumberLbl_Icon)); + + queueNumberLbl_Text = this.FindControl(nameof(queueNumberLbl_Text)); + errorNumberLbl_Text = this.FindControl(nameof(errorNumberLbl_Text)); + completedNumberLbl_Text = this.FindControl(nameof(completedNumberLbl_Text)); + + runningTimeLbl = this.FindControl(nameof(runningTimeLbl)); + + toolStripProgressBar1 = this.FindControl(nameof(toolStripProgressBar1)); + + Logger = ProcessQueue.LogMe.RegisterForm(this); + + Queue.QueuededCountChanged += Queue_QueuededCountChanged; + Queue.CompletedCountChanged += Queue_CompletedCountChanged; + + if (Design.IsDesignMode) + return; + + runningTimeLbl.Text = string.Empty; + QueuedCount = 0; + ErrorCount = 0; + CompletedCount = 0; + + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private async void ProcessBookControl2_ButtonClicked(ProcessBook2 item, QueueButton queueButton) + { + switch (queueButton) + { + case QueueButton.MoveFirst: + Queue.MoveQueuePosition(item, QueuePosition.Fisrt); + break; + case QueueButton.MoveUp: + Queue.MoveQueuePosition(item, QueuePosition.OneUp); + break; + case QueueButton.MoveDown: + Queue.MoveQueuePosition(item, QueuePosition.OneDown); + break; + case QueueButton.MoveLast: + Queue.MoveQueuePosition(item, QueuePosition.Last); + break; + case QueueButton.Cancel: + if (item is not null) + await item.CancelAsync(); + Queue.RemoveQueued(item); + break; + } + } + + private void RepeaterClick(object sender, PointerPressedEventArgs e) + { + if ((e.Source as TextBlock)?.DataContext is ProcessBook2 item) + { + _viewModel.SelectedItem = item; + _selectedIndex = _viewModel.Items.IndexOf(item); + } + } + + private void RepeaterOnKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.F5) + { + //_viewModel.ResetItems(); + } + } + public async void CancelAllBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Queue.ClearQueue(); + if (Queue.Current is not null) + await Queue.Current.CancelAsync(); + } + + public void ClearFinishedBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Queue.ClearCompleted(); + + if (!Running) + runningTimeLbl.Text = string.Empty; + } + + private bool isBookInQueue(DataLayer.LibraryBook libraryBook) + => Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); + + public void AddDownloadPdf(DataLayer.LibraryBook libraryBook) + => AddDownloadPdf(new List() { libraryBook }); + + public void AddDownloadDecrypt(DataLayer.LibraryBook libraryBook) + => AddDownloadDecrypt(new List() { libraryBook }); + + public void AddConvertMp3(DataLayer.LibraryBook libraryBook) + => AddConvertMp3(new List() { libraryBook }); + + public void AddDownloadPdf(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + ProcessBook2 pbook = new(entry, Logger); + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + public void AddDownloadDecrypt(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + ProcessBook2 pbook = new(entry, Logger); + pbook.AddDownloadDecryptBook(); + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + public void AddConvertMp3(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + ProcessBook2 pbook = new(entry, Logger); + pbook.AddConvertToMp3(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + private void AddToQueue(IEnumerable pbook) + { + Dispatcher.UIThread.Post(() => + { + Queue.Enqueue(pbook); + if (!Running) + QueueRunner = QueueLoop(); + }); + } + + DateTime StartingTime; + private async Task QueueLoop() + { + try + { + Serilog.Log.Logger.Information("Begin processing queue"); + + StartingTime = DateTime.Now; + + using var counterTimer = new System.Threading.Timer(CounterTimer_Tick, null, 0, 500); + + while (Queue.MoveNext()) + { + var nextBook = Queue.Current; + + Serilog.Log.Logger.Information("Begin processing queued item. {item_LibraryBook}", nextBook?.LibraryBook); + + var result = await nextBook.ProcessOneAsync(); + + Serilog.Log.Logger.Information("Completed processing queued item: {item_LibraryBook}\r\nResult: {result}", nextBook?.LibraryBook, result); + + if (result == ProcessBookResult.ValidationFail) + Queue.ClearCurrent(); + else if (result == ProcessBookResult.FailedAbort) + Queue.ClearQueue(); + else if (result == ProcessBookResult.FailedSkip) + nextBook.LibraryBook.Book.UpdateBookStatus(DataLayer.LiberatedStatus.Error); + } + Serilog.Log.Logger.Information("Completed processing queue"); + + Queue_CompletedCountChanged(this, 0); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error was encountered while processing queued items"); + } + } + + public void WriteLine(string text) + { + + } + + #region Control event handlers + + private void Queue_CompletedCountChanged(object sender, int e) + { + int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail); + int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success); + + ErrorCount = errCount; + CompletedCount = completeCount; + UpdateProgressBar(); + } + private void Queue_QueuededCountChanged(object sender, int cueCount) + { + QueuedCount = cueCount; + UpdateProgressBar(); + } + private void UpdateProgressBar() + { + double percent = 100d * Queue.Completed.Count / Queue.Count; + toolStripProgressBar1.Value = percent; + } + + private async void cancelAllBtn_Click(object sender, EventArgs e) + { + Queue.ClearQueue(); + if (Queue.Current is not null) + await Queue.Current.CancelAsync(); + } + + private void btnClearFinished_Click(object sender, EventArgs e) + { + Queue.ClearCompleted(); + + if (!Running) + runningTimeLbl.Text = string.Empty; + } + + private void CounterTimer_Tick(object? state) + { + string timeToStr(TimeSpan time) + { + string minsSecs = $"{time:mm\\:ss}"; + if (time.TotalHours >= 1) + return $"{time.TotalHours:F0}:{minsSecs}"; + return minsSecs; + } + + if (Running) + Dispatcher.UIThread.Post(() => runningTimeLbl.Text = timeToStr(DateTime.Now - StartingTime)); + } + + #endregion + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml new file mode 100644 index 00000000..9065dd7f --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +