From fd16e97632578c210a435039789c76272a556d9e Mon Sep 17 00:00:00 2001 From: Mbucari Date: Fri, 17 Mar 2023 14:06:02 -0600 Subject: [PATCH 01/16] When book is unavailable, check other accounts (#535) --- Source/DataLayer/EfClasses/LibraryBook.cs | 4 +- .../DtoImporterService/LibraryBookImporter.cs | 79 +++++++++++-------- 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/Source/DataLayer/EfClasses/LibraryBook.cs b/Source/DataLayer/EfClasses/LibraryBook.cs index 449a2477..32ef263d 100644 --- a/Source/DataLayer/EfClasses/LibraryBook.cs +++ b/Source/DataLayer/EfClasses/LibraryBook.cs @@ -25,6 +25,8 @@ public LibraryBook(Book book, DateTime dateAdded, string account) Account = account; } - public override string ToString() => $"{DateAdded:d} {Book}"; + public void SetAccount(string account) => Account = account; + + public override string ToString() => $"{DateAdded:d} {Book}"; } } diff --git a/Source/DtoImporterService/LibraryBookImporter.cs b/Source/DtoImporterService/LibraryBookImporter.cs index 411c3885..58583be2 100644 --- a/Source/DtoImporterService/LibraryBookImporter.cs +++ b/Source/DtoImporterService/LibraryBookImporter.cs @@ -41,32 +41,41 @@ private int upsertLibraryBooks(IEnumerable importItems) // // CURRENT SOLUTION: don't re-insert - var newItems = importItems - .ExceptBy(DbContext.LibraryBooks.Select(lb => lb.Book.AudibleProductId), imp => imp.DtoItem.ProductId) - .ToList(); - - // if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin. - // just use the first - var hash = newItems.ToDictionarySafe(dto => dto.DtoItem.ProductId); - foreach (var kvp in hash) - { - var newItem = kvp.Value; + var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionary(l => l.Book.AudibleProductId); + var hash = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak); + int qtyNew = 0; - var libraryBook = new LibraryBook( - bookImporter.Cache[newItem.DtoItem.ProductId], - newItem.DtoItem.DateAdded, - newItem.AccountId) + foreach (var item in hash.Values) + { + if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing)) { - AbsentFromLastScan = isPlusTitleUnavailable(newItem) - }; + if (existing.Account != item.AccountId) + { + //Book is absent from the existing LibraryBook's account. Use the alternate account. + existing.SetAccount(item.AccountId); + } - try - { - DbContext.LibraryBooks.Add(libraryBook); + existing.AbsentFromLastScan = isPlusTitleUnavailable(item); } - catch (Exception ex) + else { - Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account }); + var libraryBook = new LibraryBook( + bookImporter.Cache[item.DtoItem.ProductId], + item.DtoItem.DateAdded, + item.AccountId) + { + AbsentFromLastScan = isPlusTitleUnavailable(item) + }; + + try + { + DbContext.LibraryBooks.Add(libraryBook); + qtyNew++; + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account }); + } } } @@ -77,20 +86,28 @@ private int upsertLibraryBooks(IEnumerable importItems) foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts))) nullBook.AbsentFromLastScan = true; - //Join importItems on LibraryBooks before iterating over LibraryBooks to avoid - //quadratic complexity caused by searching all of importItems for each LibraryBook. - //Join uses hashing, so complexity should approach O(N) instead of O(N^2). - var items_lbs - = importItems - .Join(DbContext.LibraryBooks, o => (o.AccountId, o.DtoItem.ProductId), i => (i.Account, i.Book?.AudibleProductId), (o, i) => (o, i)); + return qtyNew; + } + + private static Dictionary ToDictionarySafe(IEnumerable source, Func keySelector, Func tieBreaker) + { + var dictionary = new Dictionary(); - foreach ((ImportItem item, LibraryBook lb) in items_lbs) - lb.AbsentFromLastScan = isPlusTitleUnavailable(item); + foreach (TSource newItem in source) + { + TKey key = keySelector(newItem); - var qtyNew = hash.Count; - return qtyNew; + dictionary[key] + = dictionary.TryGetValue(key, out TSource existingItem) + ? tieBreaker(existingItem, newItem) + : newItem; + } + return dictionary; } + private static ImportItem tieBreak(ImportItem item1, ImportItem item2) + => isPlusTitleUnavailable(item1) && !isPlusTitleUnavailable(item2) ? item2 : item1; + private static bool isPlusTitleUnavailable(ImportItem item) => item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true; From 56de1e7659f6c44636429ac41d0ebb09f79b885c Mon Sep 17 00:00:00 2001 From: MBucari Date: Fri, 17 Mar 2023 17:47:27 -0600 Subject: [PATCH 02/16] Preserve "expanded" status when updating library --- Source/LibationUiBase/GridView/GridEntry[TStatus].cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs index 0502b3f6..2e5c08b0 100644 --- a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs @@ -34,6 +34,7 @@ public abstract class GridEntry : SynchronizeInvoker, IGridEntry where #region Model properties exposed to the view protected bool? remove = false; + private EntryStatus _liberate; private string _purchasedate; private string _length; private LastDownloadStatus _lastDownload; @@ -50,7 +51,7 @@ public abstract class GridEntry : SynchronizeInvoker, IGridEntry where private Rating _myRating; public abstract bool? Remove { get; set; } - public EntryStatus Liberate { get; private set; } + public EntryStatus Liberate { get => _liberate; private set => RaiseAndSetIfChanged(ref _liberate, value); } public string PurchaseDate { get => _purchasedate; protected set => RaiseAndSetIfChanged(ref _purchasedate, value); } public string Length { get => _length; protected set => RaiseAndSetIfChanged(ref _length, value); } public LastDownloadStatus LastDownload { get => _lastDownload; protected set => RaiseAndSetIfChanged(ref _lastDownload, value); } @@ -98,7 +99,10 @@ public void UpdateLibraryBook(LibraryBook libraryBook) LibraryBook = libraryBook; + var expanded = Liberate?.Expanded ?? false; Liberate = TStatus.Create(libraryBook); + Liberate.Expanded = expanded; + Title = Book.Title; Series = Book.SeriesNames(); Length = GetBookLengthString(); From 2725340994228428a3ad39175a15b5f3dadb8fe8 Mon Sep 17 00:00:00 2001 From: MBucari Date: Fri, 17 Mar 2023 17:51:39 -0600 Subject: [PATCH 03/16] Suppress VisibleCountChanged firing when updating grid --- .../ViewModels/ProductsDisplayViewModel.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 1420bc98..452d0848 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -5,6 +5,7 @@ using DataLayer; using LibationAvalonia.Dialogs.Login; using LibationUiBase.GridView; +using NPOI.SS.Formula.Functions; using ReactiveUI; using System; using System.Collections.Generic; @@ -116,17 +117,20 @@ internal void BindToGrid(List dbBooks) //Create the filtered-in list before adding entries to avoid a refresh FilteredInGridEntries = QueryResults(geList, FilterString); SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded)); - GridEntries.CollectionChanged += (_, _) - => VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); - - VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); + GridEntries.CollectionChanged += GridEntries_CollectionChanged; + GridEntries_CollectionChanged(); } + private void GridEntries_CollectionChanged(object sender = null, EventArgs e = null) + => VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); + /// /// Call when there's been a change to the library /// internal async Task UpdateGridAsync(List dbBooks) { + GridEntries.CollectionChanged -= GridEntries_CollectionChanged; + #region Add new or update existing grid entries //Add absent entries to grid, or update existing entry @@ -176,6 +180,9 @@ await Dispatcher.UIThread.InvokeAsync(() => await Filter(FilterString); GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true); + + GridEntries.CollectionChanged += GridEntries_CollectionChanged; + GridEntries_CollectionChanged(); } private void RemoveBooks(IEnumerable removedBooks, IEnumerable removedSeries) From 18cf20ecad0ec2952273d29c2cd128c2cd3d9ee8 Mon Sep 17 00:00:00 2001 From: MBucari Date: Fri, 17 Mar 2023 19:31:36 -0600 Subject: [PATCH 04/16] All books that pass the filter are counted as "visible" (#536) --- .../ViewModels/ProductsDisplayViewModel.cs | 21 +++++++++++++------ .../GridView/GridEntryBindingList.cs | 16 ++++++++++++-- .../LibationWinForms/GridView/ProductsGrid.cs | 12 +++++------ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 452d0848..604b37c3 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -5,7 +5,6 @@ using DataLayer; using LibationAvalonia.Dialogs.Login; using LibationUiBase.GridView; -using NPOI.SS.Formula.Functions; using ReactiveUI; using System; using System.Collections.Generic; @@ -32,10 +31,14 @@ public class ProductsDisplayViewModel : ViewModelBase public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); } public List GetVisibleBookEntries() - => GridEntries - .OfType() - .Select(lbe => lbe.LibraryBook) - .ToList(); + => FilteredInGridEntries? + .OfType() + .Select(lbe => lbe.LibraryBook) + .ToList() + ?? SOURCE + .OfType() + .Select(lbe => lbe.LibraryBook) + .ToList(); private IEnumerable GetAllBookEntries() => SOURCE @@ -122,7 +125,13 @@ internal void BindToGrid(List dbBooks) } private void GridEntries_CollectionChanged(object sender = null, EventArgs e = null) - => VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); + { + var count + = FilteredInGridEntries?.OfType().Count() + ?? SOURCE.OfType().Count(); + + VisibleCountChanged?.Invoke(this, count); + } /// /// Call when there's been a change to the library diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 7350334b..9203ad53 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -28,6 +28,18 @@ public GridEntryBindingList(IEnumerable enumeration) : base(new List /// All items in the list, including those filtered out. public List AllItems() => Items.Concat(FilterRemoved).ToList(); + + /// All items that pass the current filter + public IEnumerable GetFilteredInItems() + => SearchResults is null + ? FilterRemoved + .OfType() + .Union(Items.OfType()) + : FilterRemoved + .OfType() + .Join(SearchResults.Docs, o => o.Book.AudibleProductId, i => i.ProductId, (o, _) => o) + .Union(Items.OfType()); + public bool SupportsFiltering => true; public string Filter { get => FilterString; set => ApplyFilter(value); } @@ -102,7 +114,7 @@ public void ExpandAll() public void CollapseItem(ISeriesEntry sEntry) { - foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList()) + foreach (var episode in sEntry.Children.Join(Items.BookEntries(), o => o, i => i, (_, i) => i).ToList()) { FilterRemoved.Add(episode); base.Remove(episode); @@ -115,7 +127,7 @@ public void ExpandItem(ISeriesEntry sEntry) { var sindex = Items.IndexOf(sEntry); - foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).ToList()) + foreach (var episode in sEntry.Children.Join(FilterRemoved.BookEntries(), o => o, i => i, (_, i) => i).ToList()) { if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) { diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index c5d59792..b7b8815f 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; using System.Data; -using System.Diagnostics; using System.Drawing; using System.Linq; using System.Threading.Tasks; @@ -34,7 +33,7 @@ public partial class ProductsGrid : UserControl private GridEntryBindingList bindingList; internal IEnumerable GetVisibleBooks() => bindingList - .BookEntries() + .GetFilteredInItems() .Select(lbe => lbe.LibraryBook); internal IEnumerable GetAllBookEntries() => bindingList.AllItems().BookEntries(); @@ -84,7 +83,7 @@ private void DataGridView_CellContentClick(object sender, DataGridViewCellEventA else bindingList.ExpandItem(sEntry); - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); } else if (e.ColumnIndex == descriptionGVColumn.Index) DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); @@ -248,7 +247,7 @@ internal void BindToGrid(List dbBooks) bindingList = new GridEntryBindingList(geList.OrderByDescending(e => e.DateAdded)); bindingList.CollapseAll(); syncBindingSource.DataSource = bindingList; - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); } internal void UpdateGrid(List dbBooks) @@ -317,7 +316,7 @@ public void RemoveBooks(IEnumerable removedBooks) //no need to re-filter for removed books bindingList.Remove(removed); - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); } private void AddOrUpdateBook(LibraryBook book, ILibraryBookEntry existingBookEntry) @@ -364,6 +363,7 @@ private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry exist //Series exists. Create and add episode child then update the SeriesEntry episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry); seriesEntry.Children.Add(episodeEntry); + seriesEntry.Children.Sort((c1,c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex)); var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); seriesEntry.UpdateLibraryBook(seriesBook); } @@ -395,7 +395,7 @@ public void Filter(string searchString) syncBindingSource.Filter = searchString; if (visibleCount != bindingList.Count) - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); } #endregion From 565c84c4ab4f693882677b958c61d35b3c794b25 Mon Sep 17 00:00:00 2001 From: MBucari Date: Fri, 17 Mar 2023 21:59:37 -0600 Subject: [PATCH 05/16] Add series # to grid display (#529) --- Source/DataLayer/EfClasses/SeriesBook.cs | 5 +++-- Source/DataLayer/EntityExtensions.cs | 13 +++++++++---- .../LibationUiBase/GridView/GridEntry[TStatus].cs | 2 +- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Source/DataLayer/EfClasses/SeriesBook.cs b/Source/DataLayer/EfClasses/SeriesBook.cs index 825d2515..fa430a65 100644 --- a/Source/DataLayer/EfClasses/SeriesBook.cs +++ b/Source/DataLayer/EfClasses/SeriesBook.cs @@ -8,7 +8,7 @@ public class SeriesBook internal int BookId { get; private set; } public string Order { get; private set; } - public float Index => StringLib.ExtractFirstNumber(Order); + public float Index { get; } public Series Series { get; private set; } public Book Book { get; private set; } @@ -22,7 +22,8 @@ internal SeriesBook(Series series, Book book, string order) Series = series; Book = book; Order = order; - } + Index = StringLib.ExtractFirstNumber(Order); + } public void UpdateOrder(string order) { diff --git a/Source/DataLayer/EntityExtensions.cs b/Source/DataLayer/EntityExtensions.cs index acf0b866..d5b264cf 100644 --- a/Source/DataLayer/EntityExtensions.cs +++ b/Source/DataLayer/EntityExtensions.cs @@ -18,9 +18,9 @@ public static class EntityExtensions /// True if exists and IsLiberated. Else false public static bool PDF_Exists(this Book book) => book.UserDefinedItem.PdfStatus == LiberatedStatus.Liberated; - public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames()); + public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames(true)); public static bool HasPdf(this Book book) => book.Supplements.Any(); - public static string SeriesNames(this Book book) + public static string SeriesNames(this Book book, bool includeIndex = false) { if (book.SeriesLink is null) return ""; @@ -28,7 +28,7 @@ public static string SeriesNames(this Book book) // first: alphabetical by name var withNames = book.SeriesLink .Where(s => !string.IsNullOrWhiteSpace(s.Series.Name)) - .Select(s => s.Series.Name) + .Select(getSeriesNameString) .OrderBy(a => a) .ToList(); // then un-named are alpha by series id @@ -40,7 +40,12 @@ public static string SeriesNames(this Book book) var all = withNames.Union(nullNames).ToList(); return string.Join(", ", all); - } + + string getSeriesNameString(SeriesBook sb) + => includeIndex && !string.IsNullOrWhiteSpace(sb.Order) && sb.Order != "-1" + ? $"{sb.Series.Name} (#{sb.Order})" + : sb.Series.Name; + } public static string[] CategoriesNames(this Book book) => book.Category is null ? new string[0] : book.Category.ParentCategory is null ? new[] { book.Category.Name } diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs index 2e5c08b0..4aa8fdde 100644 --- a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs @@ -104,7 +104,7 @@ public void UpdateLibraryBook(LibraryBook libraryBook) Liberate.Expanded = expanded; Title = Book.Title; - Series = Book.SeriesNames(); + Series = Book.SeriesNames(includeIndex: true); Length = GetBookLengthString(); //Ratings are changed using Update(), which is a problem for Avalonia data bindings because //the reference doesn't change. Clone the rating so that it updates within Avalonia properly. From 99687e968e81f8f945d05b7637b3f55bd1752220 Mon Sep 17 00:00:00 2001 From: MBucari Date: Sun, 19 Mar 2023 10:19:38 -0600 Subject: [PATCH 06/16] Create books directory if not found (#542) --- Source/LibationFileManager/Configuration.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Source/LibationFileManager/Configuration.cs b/Source/LibationFileManager/Configuration.cs index 3c71abe9..b1d6c671 100644 --- a/Source/LibationFileManager/Configuration.cs +++ b/Source/LibationFileManager/Configuration.cs @@ -16,11 +16,22 @@ public static bool SettingsFileIsValid(string settingsFile) if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile)) return false; - var pDic = new PersistentDictionary(settingsFile, isReadOnly: true); + var pDic = new PersistentDictionary(settingsFile, isReadOnly: false); var booksDir = pDic.GetString(nameof(Books)); if (booksDir is null || !Directory.Exists(booksDir)) - return false; + { + booksDir = Path.Combine(Path.GetDirectoryName(settingsFile), nameof(Books)); + try + { + Directory.CreateDirectory(booksDir); + + pDic.SetString(nameof(Books), booksDir); + + return booksDir is not null && Directory.Exists(booksDir); + } + catch { return false; } + } return true; } From 784ab73a36a6c979610bfb72adee4e491f48fa91 Mon Sep 17 00:00:00 2001 From: MBucari Date: Sun, 19 Mar 2023 11:44:42 -0600 Subject: [PATCH 07/16] Add context menu to Series grid entries (#536) --- .../Views/MainWindow.ProcessQueue.cs | 18 +++ .../LibationAvalonia/Views/MainWindow.axaml | 1 + .../Views/ProductsDisplay.axaml.cs | 144 ++++++++++++------ .../GridView/SeriesEntry[TStatus].cs | 4 +- Source/LibationWinForms/Form1.Designer.cs | 1 + Source/LibationWinForms/Form1.ProcessQueue.cs | 18 +++ .../GridView/ProductsDisplay.Designer.cs | 46 +++--- .../GridView/ProductsDisplay.cs | 127 +++++++++++++++ .../GridView/ProductsGrid.Designer.cs | 1 - .../LibationWinForms/GridView/ProductsGrid.cs | 136 +++++------------ 10 files changed, 328 insertions(+), 168 deletions(-) diff --git a/Source/LibationAvalonia/Views/MainWindow.ProcessQueue.cs b/Source/LibationAvalonia/Views/MainWindow.ProcessQueue.cs index 77e8b626..b63a183b 100644 --- a/Source/LibationAvalonia/Views/MainWindow.ProcessQueue.cs +++ b/Source/LibationAvalonia/Views/MainWindow.ProcessQueue.cs @@ -1,7 +1,9 @@ using DataLayer; using Dinah.Core; using LibationFileManager; +using LibationUiBase.GridView; using System; +using System.Collections.Generic; using System.Linq; namespace LibationAvalonia.Views @@ -48,6 +50,22 @@ public async void ProductsDisplay_LiberateClicked(object sender, LibraryBook lib } } + public void ProductsDisplay_LiberateSeriesClicked(object sender, ISeriesEntry series) + { + try + { + SetQueueCollapseState(false); + + Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); + + _viewModel.ProcessQueue.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated()); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error occurred while backing up {series} episodes", series.LibraryBook); + } + } + public void ProductsDisplay_ConvertToMp3Clicked(object sender, LibraryBook libraryBook) { try diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml index f6490cd1..d2feee33 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow.axaml @@ -195,6 +195,7 @@ Name="productsDisplay" DataContext="{Binding ProductsDisplay}" LiberateClicked="ProductsDisplay_LiberateClicked" + LiberateSeriesClicked="ProductsDisplay_LiberateSeriesClicked" ConvertToMp3Clicked="ProductsDisplay_ConvertToMp3Clicked" /> diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 1461e6d7..1d107bf2 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -20,6 +20,7 @@ namespace LibationAvalonia.Views public partial class ProductsDisplay : UserControl { public event EventHandler LiberateClicked; + public event EventHandler LiberateSeriesClicked; public event EventHandler ConvertToMp3Clicked; private ProductsDisplayViewModel _viewModel => DataContext as ProductsDisplayViewModel; @@ -90,74 +91,131 @@ public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellC { var entry = args.GridEntry; + #region Liberate all Episodes + if (entry.Liberate.IsSeries) - return; + { + var liberateEpisodesMenuItem = new MenuItem() + { + Header = "_Liberate All Episodes", + IsEnabled = ((ISeriesEntry)entry).Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + }; + + args.ContextMenuItems.Add(liberateEpisodesMenuItem); + + liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, ((ISeriesEntry)entry)); + } + + #endregion + #region Set Download status to Downloaded var setDownloadMenuItem = new MenuItem() { Header = "Set Download status to '_Downloaded'", - IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated + IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || entry.Liberate.IsSeries }; - setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated); + + args.ContextMenuItems.Add(setDownloadMenuItem); + + if (entry.Liberate.IsSeries) + setDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.Liberated); + else + setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated); + + #endregion + #region Set Download status to Not Downloaded var setNotDownloadMenuItem = new MenuItem() { Header = "Set Download status to '_Not Downloaded'", - IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated + IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || entry.Liberate.IsSeries }; - setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); + + args.ContextMenuItems.Add(setNotDownloadMenuItem); + + if (entry.Liberate.IsSeries) + setNotDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.NotLiberated); + else + setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); + + #endregion + #region Remove from library var removeMenuItem = new MenuItem() { Header = "_Remove from library" }; - removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook); - var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." }; - locateFileMenuItem.Click += async (_, __) => + args.ContextMenuItems.Add(removeMenuItem); + + if (entry.Liberate.IsSeries) + removeMenuItem.Click += async (_, __) => await ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).RemoveBooksAsync(); + else + removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook); + + #endregion + + if (!entry.Liberate.IsSeries) { - try + #region Locate file + var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." }; + + args.ContextMenuItems.Add(locateFileMenuItem); + + locateFileMenuItem.Click += async (_, __) => { - var openFileDialogOptions = new FilePickerOpenOptions + try { - Title = $"Locate the audio file for '{entry.Book.Title}'", - AllowMultiple = false, - SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix), - FileTypeFilter = new FilePickerFileType[] + var openFileDialogOptions = new FilePickerOpenOptions { + Title = $"Locate the audio file for '{entry.Book.Title}'", + AllowMultiple = false, + SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix), + FileTypeFilter = new FilePickerFileType[] + { new("All files (*.*)") { Patterns = new[] { "*" } }, - } - }; + } + }; - var selectedFiles = await this.GetParentWindow().StorageProvider.OpenFilePickerAsync(openFileDialogOptions); - var selectedFile = selectedFiles.SingleOrDefault(); + var selectedFiles = await this.GetParentWindow().StorageProvider.OpenFilePickerAsync(openFileDialogOptions); + var selectedFile = selectedFiles.SingleOrDefault(); - if (selectedFile?.TryGetUri(out var uri) is true) - FilePathCache.Insert(entry.AudibleProductId, uri.LocalPath); - } - catch (Exception ex) + if (selectedFile?.TryGetUri(out var uri) is true) + FilePathCache.Insert(entry.AudibleProductId, uri.LocalPath); + } + catch (Exception ex) + { + var msg = "Error saving book's location"; + await MessageBox.ShowAdminAlert(null, msg, msg, ex); + } + }; + + #endregion + #region Convert to Mp3 + var convertToMp3MenuItem = new MenuItem { - var msg = "Error saving book's location"; - await MessageBox.ShowAdminAlert(null, msg, msg, ex); - } - }; - var convertToMp3MenuItem = new MenuItem - { - Header = "_Convert to Mp3", - IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated - }; - convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook); + Header = "_Convert to Mp3", + IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated + }; + args.ContextMenuItems.Add(convertToMp3MenuItem); + + convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook); + + #endregion + } + + args.ContextMenuItems.Add(new Separator()); - var bookRecordMenuItem = new MenuItem { Header = "View _Bookmarks/Clips" }; - bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window); + #region View Bookmarks/Clips - args.ContextMenuItems.AddRange(new Control[] + if (!entry.Liberate.IsSeries) { - setDownloadMenuItem, - setNotDownloadMenuItem, - removeMenuItem, - locateFileMenuItem, - convertToMp3MenuItem, - new Separator(), - bookRecordMenuItem - }); + + var bookRecordMenuItem = new MenuItem { Header = "View _Bookmarks/Clips" }; + + args.ContextMenuItems.Add(bookRecordMenuItem); + + bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window); + } + + #endregion } else { diff --git a/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs index 37fd472e..f3ed63fc 100644 --- a/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs @@ -62,7 +62,7 @@ public void RemoveChild(ILibraryBookEntry lbe) } protected override string GetBookTags() => null; - protected override int GetLengthInMinutes() => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); - protected override DateTime GetPurchaseDate() => Children.Min(c => c.LibraryBook.DateAdded); + protected override int GetLengthInMinutes() => Children.Count == 0 ? 0 : Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); + protected override DateTime GetPurchaseDate() => Children.Count == 0 ? default : Children.Min(c => c.LibraryBook.DateAdded); } } diff --git a/Source/LibationWinForms/Form1.Designer.cs b/Source/LibationWinForms/Form1.Designer.cs index ef404581..995bf2ed 100644 --- a/Source/LibationWinForms/Form1.Designer.cs +++ b/Source/LibationWinForms/Form1.Designer.cs @@ -528,6 +528,7 @@ private void InitializeComponent() this.productsDisplay.VisibleCountChanged += new System.EventHandler(this.productsDisplay_VisibleCountChanged); this.productsDisplay.RemovableCountChanged += new System.EventHandler(this.productsDisplay_RemovableCountChanged); this.productsDisplay.LiberateClicked += new System.EventHandler(this.ProductsDisplay_LiberateClicked); + this.productsDisplay.LiberateSeriesClicked += new System.EventHandler(this.ProductsDisplay_LiberateSeriesClicked); this.productsDisplay.ConvertToMp3Clicked += new System.EventHandler(this.ProductsDisplay_ConvertToMp3Clicked); this.productsDisplay.InitialLoaded += new System.EventHandler(this.productsDisplay_InitialLoaded); // diff --git a/Source/LibationWinForms/Form1.ProcessQueue.cs b/Source/LibationWinForms/Form1.ProcessQueue.cs index dfd7ac90..cf3b3863 100644 --- a/Source/LibationWinForms/Form1.ProcessQueue.cs +++ b/Source/LibationWinForms/Form1.ProcessQueue.cs @@ -1,8 +1,10 @@ using DataLayer; using Dinah.Core; using LibationFileManager; +using LibationUiBase.GridView; using LibationWinForms.ProcessQueue; using System; +using System.Collections.Generic; using System.Linq; using System.Windows.Forms; @@ -56,6 +58,22 @@ private void ProductsDisplay_LiberateClicked(object sender, LibraryBook libraryB } } + private void ProductsDisplay_LiberateSeriesClicked(object sender, ISeriesEntry series) + { + try + { + SetQueueCollapseState(false); + + Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); + + processBookQueue1.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated()); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error occurred while backing up {series} episodes", series.LibraryBook); + } + } + private void ProductsDisplay_ConvertToMp3Clicked(object sender, LibraryBook libraryBook) { try diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs index 1e32af52..879d2f62 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs @@ -28,35 +28,35 @@ protected override void Dispose(bool disposing) /// private void InitializeComponent() { - this.productsGrid = new LibationWinForms.GridView.ProductsGrid(); - this.SuspendLayout(); + productsGrid = new ProductsGrid(); + SuspendLayout(); // // productsGrid // - this.productsGrid.AutoScroll = true; - this.productsGrid.Dock = System.Windows.Forms.DockStyle.Fill; - this.productsGrid.Location = new System.Drawing.Point(0, 0); - this.productsGrid.Name = "productsGrid"; - this.productsGrid.Size = new System.Drawing.Size(1510, 380); - this.productsGrid.TabIndex = 0; - this.productsGrid.VisibleCountChanged += new System.EventHandler(this.productsGrid_VisibleCountChanged); - this.productsGrid.LiberateClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked); - this.productsGrid.ConvertToMp3Clicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_ConvertToMp3Clicked); - this.productsGrid.CoverClicked += new LibationWinForms.GridView.GridEntryClickedEventHandler(this.productsGrid_CoverClicked); - this.productsGrid.DetailsClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked); - this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.GridEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked); - this.productsGrid.RemovableCountChanged += new System.EventHandler(this.productsGrid_RemovableCountChanged); + productsGrid.AutoScroll = true; + productsGrid.Dock = System.Windows.Forms.DockStyle.Fill; + productsGrid.Location = new System.Drawing.Point(0, 0); + productsGrid.Name = "productsGrid"; + productsGrid.Size = new System.Drawing.Size(1510, 380); + productsGrid.TabIndex = 0; + productsGrid.VisibleCountChanged += productsGrid_VisibleCountChanged; + productsGrid.LiberateClicked += productsGrid_LiberateClicked; + productsGrid.ConvertToMp3Clicked += productsGrid_ConvertToMp3Clicked; + productsGrid.CoverClicked += productsGrid_CoverClicked; + productsGrid.DetailsClicked += productsGrid_DetailsClicked; + productsGrid.DescriptionClicked += productsGrid_DescriptionClicked; + productsGrid.RemovableCountChanged += productsGrid_RemovableCountChanged; + productsGrid.LiberateContextMenuStripNeeded += productsGrid_CellContextMenuStripNeeded; // // ProductsDisplay // - this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.Controls.Add(this.productsGrid); - this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.Name = "ProductsDisplay"; - this.Size = new System.Drawing.Size(1510, 380); - this.ResumeLayout(false); - + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + Controls.Add(productsGrid); + Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + Name = "ProductsDisplay"; + Size = new System.Drawing.Size(1510, 380); + ResumeLayout(false); } #endregion diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index a0bbf00a..c4659d81 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -20,6 +20,7 @@ public partial class ProductsDisplay : UserControl public event EventHandler VisibleCountChanged; public event EventHandler RemovableCountChanged; public event EventHandler LiberateClicked; + public event EventHandler LiberateSeriesClicked; public event EventHandler ConvertToMp3Clicked; public event EventHandler InitialLoaded; @@ -96,6 +97,132 @@ private void productsGrid_DetailsClicked(ILibraryBookEntry liveGridEntry) #endregion + #region Cell Context Menu + + private void productsGrid_CellContextMenuStripNeeded(IGridEntry entry, ContextMenuStrip ctxMenu) + { + #region Liberate all Episodes + + if (entry.Liberate.IsSeries) + { + var liberateEpisodesMenuItem = new ToolStripMenuItem() + { + Text = "&Liberate All Episodes", + Enabled = ((ISeriesEntry)entry).Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + }; + + ctxMenu.Items.Add(liberateEpisodesMenuItem); + + liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, (ISeriesEntry)entry); + } + + #endregion + #region Set Download status to Downloaded + + var setDownloadMenuItem = new ToolStripMenuItem() + { + Text = "Set Download status to '&Downloaded'", + Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || entry.Liberate.IsSeries + }; + + ctxMenu.Items.Add(setDownloadMenuItem); + + if (entry.Liberate.IsSeries) + setDownloadMenuItem.Click += (_, _) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.Liberated); + else + setDownloadMenuItem.Click += (_, _) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated); + + #endregion + #region Set Download status to Not Downloaded + + var setNotDownloadMenuItem = new ToolStripMenuItem() + { + Text = "Set Download status to '&Not Downloaded'", + Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || entry.Liberate.IsSeries + }; + + ctxMenu.Items.Add(setNotDownloadMenuItem); + + if (entry.Liberate.IsSeries) + setNotDownloadMenuItem.Click += (_, _) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.NotLiberated); + else + setNotDownloadMenuItem.Click += (_, _) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); + + #endregion + #region Remove from library + + var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" }; + + ctxMenu.Items.Add(removeMenuItem); + + if (entry.Liberate.IsSeries) + removeMenuItem.Click += async (_, _) => await ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).RemoveBooksAsync(); + else + removeMenuItem.Click += async (_, _) => await Task.Run(entry.LibraryBook.RemoveBook); + + #endregion + + if (!entry.Liberate.IsSeries) + { + #region Locate file + var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." }; + + ctxMenu.Items.Add(locateFileMenuItem); + + locateFileMenuItem.Click += (_, _) => + { + try + { + var openFileDialog = new OpenFileDialog + { + Title = $"Locate the audio file for '{entry.Book.Title}'", + Filter = "All files (*.*)|*.*", + FilterIndex = 1 + }; + if (openFileDialog.ShowDialog() == DialogResult.OK) + FilePathCache.Insert(entry.AudibleProductId, openFileDialog.FileName); + } + catch (Exception ex) + { + var msg = "Error saving book's location"; + MessageBoxLib.ShowAdminAlert(this, msg, msg, ex); + } + }; + + #endregion + #region Convert to Mp3 + + var convertToMp3MenuItem = new ToolStripMenuItem + { + Text = "&Convert to Mp3", + Enabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated + }; + + ctxMenu.Items.Add(convertToMp3MenuItem); + + convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook); + + #endregion + } + + ctxMenu.Items.Add(new ToolStripSeparator()); + + #region View Bookmarks/Clips + + if (!entry.Liberate.IsSeries) + { + var bookRecordMenuItem = new ToolStripMenuItem { Text = "View &Bookmarks/Clips" }; + + ctxMenu.Items.Add(bookRecordMenuItem); + + bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this); + } + + #endregion + } + + #endregion + #region Scan and Remove Books public void CloseRemoveBooksColumn() diff --git a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs index 51b5d09b..3bc46638 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs @@ -99,7 +99,6 @@ private void InitializeComponent() this.gridEntryDataGridView.Size = new System.Drawing.Size(1570, 380); this.gridEntryDataGridView.TabIndex = 0; this.gridEntryDataGridView.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView_CellContentClick); - this.gridEntryDataGridView.CellContextMenuStripNeeded += new System.Windows.Forms.DataGridViewCellContextMenuStripNeededEventHandler(this.gridEntryDataGridView_CellContextMenuStripNeeded); this.gridEntryDataGridView.CellToolTipTextNeeded += new System.Windows.Forms.DataGridViewCellToolTipTextNeededEventHandler(this.gridEntryDataGridView_CellToolTipTextNeeded); // // removeGVColumn diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index b7b8815f..0c09e8fc 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -1,15 +1,12 @@ -using ApplicationServices; -using DataLayer; +using DataLayer; using Dinah.Core.WindowsDesktop.Forms; using LibationFileManager; using LibationUiBase.GridView; -using LibationWinForms.Dialogs; using System; using System.Collections.Generic; using System.Data; using System.Drawing; using System.Linq; -using System.Threading.Tasks; using System.Windows.Forms; namespace LibationWinForms.GridView @@ -17,6 +14,7 @@ namespace LibationWinForms.GridView public delegate void GridEntryClickedEventHandler(IGridEntry liveGridEntry); public delegate void LibraryBookEntryClickedEventHandler(ILibraryBookEntry liveGridEntry); public delegate void GridEntryRectangleClickedEventHandler(IGridEntry liveGridEntry, Rectangle cellRectangle); + public delegate void ProductsGridCellContextMenuStripNeededEventHandler(IGridEntry liveGridEntry, ContextMenuStrip ctxMenu); public partial class ProductsGrid : UserControl { @@ -29,6 +27,7 @@ public partial class ProductsGrid : UserControl public event GridEntryRectangleClickedEventHandler DescriptionClicked; public new event EventHandler Scroll; public event EventHandler RemovableCountChanged; + public event ProductsGridCellContextMenuStripNeededEventHandler LiberateContextMenuStripNeeded; private GridEntryBindingList bindingList; internal IEnumerable GetVisibleBooks() @@ -43,9 +42,43 @@ public ProductsGrid() InitializeComponent(); EnableDoubleBuffering(); gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s); + gridEntryDataGridView.CellContextMenuStripNeeded += GridEntryDataGridView_CellContextMenuStripNeeded; removeGVColumn.Frozen = false; } + private void GridEntryDataGridView_CellContextMenuStripNeeded(object sender, DataGridViewCellContextMenuStripNeededEventArgs e) + { + // header + if (e.RowIndex < 0) + return; + + // cover + else if (e.ColumnIndex == coverGVColumn.Index) + return; + + e.ContextMenuStrip = new ContextMenuStrip(); + // any non-stop light + if (e.ColumnIndex != liberateGVColumn.Index) + { + e.ContextMenuStrip.Items.Add("Copy", null, (_, __) => + { + try + { + var dgv = (DataGridView)sender; + var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString(); + Clipboard.SetDataObject(text, false, 5, 150); + } + catch { } + }); + } + else + { + var entry = getGridEntry(e.RowIndex); + var name = gridEntryDataGridView.Columns[e.ColumnIndex].DataPropertyName; + LiberateContextMenuStripNeeded?.Invoke(entry, e.ContextMenuStrip); + } + } + private void EnableDoubleBuffering() { var propertyInfo = gridEntryDataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); @@ -103,101 +136,6 @@ private void DataGridView_CellContentClick(object sender, DataGridViewCellEventA } } - private void gridEntryDataGridView_CellContextMenuStripNeeded(object sender, DataGridViewCellContextMenuStripNeededEventArgs e) - { - // header - if (e.RowIndex < 0) - return; - - // cover - if (e.ColumnIndex == coverGVColumn.Index) - return; - - // any non-stop light - if (e.ColumnIndex != liberateGVColumn.Index) - { - var copyContextMenu = new ContextMenuStrip(); - copyContextMenu.Items.Add("Copy", null, (_, __) => - { - try - { - var dgv = (DataGridView)sender; - var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString(); - Clipboard.SetDataObject(text, false, 5, 150); - } - catch { } - }); - - e.ContextMenuStrip = copyContextMenu; - return; - } - - // else: stop light - - var entry = getGridEntry(e.RowIndex); - if (entry.Liberate.IsSeries) - return; - - var setDownloadMenuItem = new ToolStripMenuItem() - { - Text = "Set Download status to '&Downloaded'", - Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated - }; - setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated); - - var setNotDownloadMenuItem = new ToolStripMenuItem() - { - Text = "Set Download status to '&Not Downloaded'", - Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated - }; - setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); - - var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" }; - removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook); - - var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." }; - locateFileMenuItem.Click += (_, __) => - { - try - { - var openFileDialog = new OpenFileDialog - { - Title = $"Locate the audio file for '{entry.Book.Title}'", - Filter = "All files (*.*)|*.*", - FilterIndex = 1 - }; - if (openFileDialog.ShowDialog() == DialogResult.OK) - FilePathCache.Insert(entry.AudibleProductId, openFileDialog.FileName); - } - catch (Exception ex) - { - var msg = "Error saving book's location"; - MessageBoxLib.ShowAdminAlert(this, msg, msg, ex); - } - }; - - var convertToMp3MenuItem = new ToolStripMenuItem - { - Text = "&Convert to Mp3", - Enabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated - }; - convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(entry as ILibraryBookEntry); - - var bookRecordMenuItem = new ToolStripMenuItem { Text = "View &Bookmarks/Clips" }; - bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this); - - var stopLightContextMenu = new ContextMenuStrip(); - stopLightContextMenu.Items.Add(setDownloadMenuItem); - stopLightContextMenu.Items.Add(setNotDownloadMenuItem); - stopLightContextMenu.Items.Add(removeMenuItem); - stopLightContextMenu.Items.Add(locateFileMenuItem); - stopLightContextMenu.Items.Add(convertToMp3MenuItem); - stopLightContextMenu.Items.Add(new ToolStripSeparator()); - stopLightContextMenu.Items.Add(bookRecordMenuItem); - - e.ContextMenuStrip = stopLightContextMenu; - } - private IGridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem(rowIndex); #endregion From 9ae1f0399b871cffa5751184d65ea22a10463701 Mon Sep 17 00:00:00 2001 From: MBucari Date: Sun, 19 Mar 2023 20:05:18 -0600 Subject: [PATCH 08/16] Add SeriesViewDialog --- Source/ApplicationServices/LibraryCommands.cs | 55 ++++- Source/AudibleUtilities/ApiExtended.cs | 6 +- Source/DataLayer/EfClasses/SeriesBook.cs | 5 +- Source/Libation.sln | 12 ++ .../Controls/LinkLabel.axaml.cs | 8 + .../ViewModels/ProductsDisplayViewModel.cs | 14 +- .../LibationAvalonia/Views/MainWindow.NoUI.cs | 3 + .../Views/ProductsDisplay.axaml.cs | 13 ++ .../Views/SeriesViewDialog.axaml | 32 +++ .../Views/SeriesViewDialog.axaml.cs | 70 +++++++ .../Views/SeriesViewGrid.axaml | 100 +++++++++ .../Views/SeriesViewGrid.axaml.cs | 90 ++++++++ Source/LibationUiBase/BaseUtil.cs | 13 ++ .../LibationUiBase/SeriesView/AyceButton.cs | 133 ++++++++++++ .../LibationUiBase/SeriesView/SeriesButton.cs | 51 +++++ .../LibationUiBase/SeriesView/SeriesEntry.cs | 151 ++++++++++++++ .../LibationUiBase/SeriesView/SeriesOrder.cs | 23 +++ .../SeriesView/WishlistButton.cs | 93 +++++++++ Source/LibationWinForms/Form1._NonUI.cs | 3 + .../GridView/ProductsDisplay.Designer.cs | 1 - .../GridView/ProductsDisplay.cs | 21 +- .../LibationWinForms/GridView/ProductsGrid.cs | 4 +- .../SeriesView/DownloadButtonColumn.cs | 49 +++++ .../SeriesView/SeriesEntryBindingList.cs | 46 +++++ .../SeriesView/SeriesViewDialog.Designer.cs | 62 ++++++ .../SeriesView/SeriesViewDialog.cs | 193 ++++++++++++++++++ .../SeriesView/SeriesViewDialog.resx | 60 ++++++ 27 files changed, 1293 insertions(+), 18 deletions(-) create mode 100644 Source/LibationAvalonia/Views/SeriesViewDialog.axaml create mode 100644 Source/LibationAvalonia/Views/SeriesViewDialog.axaml.cs create mode 100644 Source/LibationAvalonia/Views/SeriesViewGrid.axaml create mode 100644 Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs create mode 100644 Source/LibationUiBase/BaseUtil.cs create mode 100644 Source/LibationUiBase/SeriesView/AyceButton.cs create mode 100644 Source/LibationUiBase/SeriesView/SeriesButton.cs create mode 100644 Source/LibationUiBase/SeriesView/SeriesEntry.cs create mode 100644 Source/LibationUiBase/SeriesView/SeriesOrder.cs create mode 100644 Source/LibationUiBase/SeriesView/WishlistButton.cs create mode 100644 Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs create mode 100644 Source/LibationWinForms/SeriesView/SeriesEntryBindingList.cs create mode 100644 Source/LibationWinForms/SeriesView/SeriesViewDialog.Designer.cs create mode 100644 Source/LibationWinForms/SeriesView/SeriesViewDialog.cs create mode 100644 Source/LibationWinForms/SeriesView/SeriesViewDialog.resx diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 1c101306..0bc9715c 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -171,7 +171,60 @@ public static async Task> FindInactiveBooks(Func> scanAccountsAsync(Func> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions) + public static async Task ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) + { + ArgumentValidator.EnsureNotNull(item, "item"); + ArgumentValidator.EnsureNotNull(accountId, "accountId"); + ArgumentValidator.EnsureNotNull(localeName, "localeName"); + + var importItem = new ImportItem + { + DtoItem = item, + AccountId = accountId, + LocaleName = localeName + }; + + var importItems = new List { importItem }; + var validator = new LibraryValidator(); + var exceptions = validator.Validate(importItems.Select(i => i.DtoItem)); + + if (exceptions?.Any() ?? false) + { + Log.Logger.Error(new AggregateException(exceptions), "Error validating library book. {@DebugInfo}", new { item, accountId, localeName }); + return 0; + } + + using var context = DbContexts.GetContext(); + + var bookImporter = new BookImporter(context); + await Task.Run(() => bookImporter.Import(importItems)); + var book = await Task.Run(() => context.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId)); + + if (book is null) + { + book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId); + context.LibraryBooks.Add(book); + } + else + { + book.AbsentFromLastScan = false; + } + + try + { + int qtyChanged = await Task.Run(() => SaveContext(context)); + if (qtyChanged > 0) + await Task.Run(finalizeLibrarySizeChange); + return qtyChanged; + } + catch (Exception ex) + { + Log.Logger.Error(ex, "Error adding single library book to DB. {@DebugInfo}", new { item, accountId, localeName }); + return 0; + } + } + + private static async Task> scanAccountsAsync(Func> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions) { var tasks = new List>>(); diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 76c17f15..2c77a971 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -149,7 +149,7 @@ private async Task> getItemsAsync(LibraryOptions libraryOptions, bool foreach (var parent in items.Where(i => i.IsSeriesParent)) { var children = items.Where(i => i.IsEpisodes && i.Relationships.Any(r => r.Asin == parent.Asin)); - setSeries(parent, children); + SetSeries(parent, children); } sw.Stop(); @@ -232,7 +232,7 @@ private async Task> getProductsAsync(int batchNum, List asins finally { semaphore.Release(); } } - private static void setSeries(Item parent, IEnumerable children) + public static void SetSeries(Item parent, IEnumerable children) { //A series parent will always have exactly 1 Series parent.Series = new[] @@ -267,4 +267,4 @@ private static void setSeries(Item parent, IEnumerable children) } #endregion } -} \ No newline at end of file +} diff --git a/Source/DataLayer/EfClasses/SeriesBook.cs b/Source/DataLayer/EfClasses/SeriesBook.cs index fa430a65..825d2515 100644 --- a/Source/DataLayer/EfClasses/SeriesBook.cs +++ b/Source/DataLayer/EfClasses/SeriesBook.cs @@ -8,7 +8,7 @@ public class SeriesBook internal int BookId { get; private set; } public string Order { get; private set; } - public float Index { get; } + public float Index => StringLib.ExtractFirstNumber(Order); public Series Series { get; private set; } public Book Book { get; private set; } @@ -22,8 +22,7 @@ internal SeriesBook(Series series, Book book, string order) Series = series; Book = book; Order = order; - Index = StringLib.ExtractFirstNumber(Order); - } + } public void UpdateOrder(string order) { diff --git a/Source/Libation.sln b/Source/Libation.sln index e7eda5c3..f167db23 100644 --- a/Source/Libation.sln +++ b/Source/Libation.sln @@ -101,6 +101,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation UI", "Libation UI" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation CLI", "Libation CLI", "{47E27674-595D-4F7A-8CFB-127E768E1D1E}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi", "..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj", "{DF6FBE88-A9A0-4CED-86B4-F35A130F349D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi.Common", "..\..\audible api\AudibleApi\AudibleApi.Common\AudibleApi.Common.csproj", "{093E79B6-9A57-46FE-8406-DB16BADAD427}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -219,6 +223,14 @@ Global {E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.Build.0 = Release|Any CPU + {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Release|Any CPU.Build.0 = Release|Any CPU + {093E79B6-9A57-46FE-8406-DB16BADAD427}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {093E79B6-9A57-46FE-8406-DB16BADAD427}.Debug|Any CPU.Build.0 = Debug|Any CPU + {093E79B6-9A57-46FE-8406-DB16BADAD427}.Release|Any CPU.ActiveCfg = Release|Any CPU + {093E79B6-9A57-46FE-8406-DB16BADAD427}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs index 30b0d74a..951c6919 100644 --- a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs +++ b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; +using Avalonia.Media; using Avalonia.Styling; using System; @@ -14,7 +15,14 @@ public partial class LinkLabel : TextBlock, IStyleable public LinkLabel() { InitializeComponent(); + Tapped += LinkLabel_Tapped; } + + private void LinkLabel_Tapped(object sender, TappedEventArgs e) + { + Foreground = Brushes.Purple; + } + protected override void OnPointerEntered(PointerEventArgs e) { this.Cursor = HandCursor; diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 604b37c3..a6c4bac6 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -114,12 +114,20 @@ internal void BindToGrid(List dbBooks) seriesEntry.Liberate.Expanded = false; geList.Add(seriesEntry); - geList.AddRange(seriesEntry.Children); } //Create the filtered-in list before adding entries to avoid a refresh FilteredInGridEntries = QueryResults(geList, FilterString); SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded)); + + //Add all children beneath their parent + foreach (var series in SOURCE.OfType().ToList()) + { + var seriesIndex = SOURCE.IndexOf(series); + foreach (var child in series.Children) + SOURCE.Insert(++seriesIndex, child); + } + GridEntries.CollectionChanged += GridEntries_CollectionChanged; GridEntries_CollectionChanged(); } @@ -253,13 +261,15 @@ private void UpsertEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEp //Series exists. Create and add episode child then update the SeriesEntry episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry); seriesEntry.Children.Add(episodeEntry); + seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex)); var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); seriesEntry.UpdateLibraryBook(seriesBook); } //Add episode to the grid beneath the parent int seriesIndex = SOURCE.IndexOf(seriesEntry); - SOURCE.Insert(seriesIndex + 1, episodeEntry); + int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry); + SOURCE.Insert(seriesIndex + 1 + episodeIndex, episodeEntry); } else existingEpisodeEntry.UpdateLibraryBook(episodeBook); diff --git a/Source/LibationAvalonia/Views/MainWindow.NoUI.cs b/Source/LibationAvalonia/Views/MainWindow.NoUI.cs index d04623e5..acb239ad 100644 --- a/Source/LibationAvalonia/Views/MainWindow.NoUI.cs +++ b/Source/LibationAvalonia/Views/MainWindow.NoUI.cs @@ -1,4 +1,5 @@ using LibationFileManager; +using LibationUiBase; using System; using System.IO; using System.Linq; @@ -21,6 +22,8 @@ private void Configure_NonUI() App.OpenAsset("img-coverart-prod-unavailable_500x500.jpg").CopyTo(ms3); PictureStorage.SetDefaultImage(PictureSize._500x500, ms3.ToArray()); PictureStorage.SetDefaultImage(PictureSize.Native, ms3.ToArray()); + + BaseUtil.SetLoadImageDelegate(AvaloniaUtils.TryLoadImageOrDefault); } } } diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 1d107bf2..d7d84c51 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -215,6 +215,19 @@ public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellC bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window); } + #endregion + #region View All Series + + if (entry.Book.SeriesLink.Any()) + { + var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series"; + var viewSeriesMenuItem = new MenuItem { Header = header }; + + args.ContextMenuItems.Add(viewSeriesMenuItem); + + viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show(); + } + #endregion } else diff --git a/Source/LibationAvalonia/Views/SeriesViewDialog.axaml b/Source/LibationAvalonia/Views/SeriesViewDialog.axaml new file mode 100644 index 00000000..370e397d --- /dev/null +++ b/Source/LibationAvalonia/Views/SeriesViewDialog.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Views/SeriesViewDialog.axaml.cs b/Source/LibationAvalonia/Views/SeriesViewDialog.axaml.cs new file mode 100644 index 00000000..f80e0991 --- /dev/null +++ b/Source/LibationAvalonia/Views/SeriesViewDialog.axaml.cs @@ -0,0 +1,70 @@ +using AudibleApi.Common; +using AudibleApi; +using Avalonia.Controls; +using DataLayer; +using Dinah.Core; +using FileLiberator; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Collections; +using LibationAvalonia.Dialogs; +using LibationUiBase.SeriesView; +using System; +using Avalonia.Media; + +namespace LibationAvalonia.Views +{ + public partial class SeriesViewDialog : DialogWindow + { + private readonly LibraryBook LibraryBook; + public AvaloniaList TabItems { get; } = new(); + public SeriesViewDialog() + { + InitializeComponent(); + DataContext = this; + + if (Design.IsDesignMode) + { + TabItems.Add(new TabItem { Header = "This is a Header", FontSize = 14, Content = new TextBlock { Text = "Some Text" } }); + } + else + { + Loaded += SeriesViewDialog_Loaded; + } + } + + public SeriesViewDialog(LibraryBook libraryBook) : this() + { + LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, "libraryBook"); + } + + private async void SeriesViewDialog_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + try + { + var seriesEntries = await SeriesItem.GetAllSeriesItemsAsync(LibraryBook); + + foreach (var series in seriesEntries.Keys) + { + TabItems.Add(new TabItem + { + Header = series.Title, + FontSize = 14, + Content = new SeriesViewGrid(LibraryBook, series, seriesEntries[series]) + }); + } + + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error loading searies info"); + + TabItems.Add(new TabItem + { + Header = "ERROR", + Content = new TextBlock { Text = "ERROR LOADING SERIES INFO\r\n\r\n" + ex.Message, Foreground = Brushes.Red } + }); + } + } + } +} diff --git a/Source/LibationAvalonia/Views/SeriesViewGrid.axaml b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml new file mode 100644 index 00000000..0092955c --- /dev/null +++ b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs new file mode 100644 index 00000000..857a7632 --- /dev/null +++ b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml.cs @@ -0,0 +1,90 @@ +using ApplicationServices; +using AudibleApi.Common; +using AudibleUtilities; +using Avalonia.Collections; +using Avalonia.Controls; +using DataLayer; +using Dinah.Core; +using LibationAvalonia.Controls; +using LibationAvalonia.Dialogs; +using LibationFileManager; +using LibationUiBase.SeriesView; +using System.Collections.Generic; +using System.Linq; + +namespace LibationAvalonia.Views +{ + public partial class SeriesViewGrid : UserControl + { + private ImageDisplayDialog imageDisplayDialog; + private readonly LibraryBook LibraryBook; + + public AvaloniaList SeriesEntries { get; } = new(); + + public SeriesViewGrid() + { + InitializeComponent(); + DataContext = this; + } + + public SeriesViewGrid(LibraryBook libraryBook, Item series, List entries) : this() + { + LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)); + ArgumentValidator.EnsureNotNull(series, nameof(series)); + ArgumentValidator.EnsureNotNull(entries, nameof(entries)); + + SeriesEntries.AddRange(entries.OrderBy(s => s.Order)); + } + + public async void Availability_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + if (sender is Button button && button.DataContext is SeriesItem sentry && sentry.Button.HasButtonAction) + { + await sentry.Button.PerformClickAsync(LibraryBook); + } + } + public void Title_Click(object sender, Avalonia.Input.TappedEventArgs args) + { + if (sender is not LinkLabel label || label.DataContext is not SeriesItem sentry) + return; + + sentry.ViewOnAudible(LibraryBook.Book.Locale); + } + + public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) + { + if (sender is not Image tblock || tblock.DataContext is not SeriesItem sentry) + return; + + Item libraryBook = sentry.Item; + + if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible) + { + imageDisplayDialog = new ImageDisplayDialog(); + } + + var picDef = new PictureDefinition(libraryBook.PictureLarge ?? libraryBook.PictureId, PictureSize.Native); + + void PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == picDef.PictureId) + imageDisplayDialog.SetCoverBytes(e.Picture); + + PictureStorage.PictureCached -= PictureCached; + } + + PictureStorage.PictureCached += PictureCached; + (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); + var windowTitle = $"{libraryBook.Title} - Cover"; + + imageDisplayDialog.Title = windowTitle; + imageDisplayDialog.SetCoverBytes(initialImageBts); + + if (!isDefault) + PictureStorage.PictureCached -= PictureCached; + + if (!imageDisplayDialog.IsVisible) + imageDisplayDialog.Show(); + } + } +} diff --git a/Source/LibationUiBase/BaseUtil.cs b/Source/LibationUiBase/BaseUtil.cs new file mode 100644 index 00000000..4bf512d4 --- /dev/null +++ b/Source/LibationUiBase/BaseUtil.cs @@ -0,0 +1,13 @@ +using LibationFileManager; +using System; + +namespace LibationUiBase +{ + public static class BaseUtil + { + /// A delegate that loads image bytes into the the UI framework's image format. + public static Func LoadImage { get; private set; } + public static void SetLoadImageDelegate(Func tryLoadImage) + => LoadImage = tryLoadImage; + } +} diff --git a/Source/LibationUiBase/SeriesView/AyceButton.cs b/Source/LibationUiBase/SeriesView/AyceButton.cs new file mode 100644 index 00000000..07d4318c --- /dev/null +++ b/Source/LibationUiBase/SeriesView/AyceButton.cs @@ -0,0 +1,133 @@ +using ApplicationServices; +using AudibleApi; +using AudibleApi.Common; +using DataLayer; +using FileLiberator; +using System; +using System.Linq; +using Dinah.Core; +using System.Threading.Tasks; + +namespace LibationUiBase.SeriesView +{ + internal class AyceButton : SeriesButton + { + //Making this event and field static prevents concurrent additions to the library. + //Search engine indexer does not support concurrent re-indexing. + private static event EventHandler ButtonEnabled; + private static bool globalEnabled = true; + + public override bool HasButtonAction => true; + public override string DisplayText + => InLibrary ? "Remove\r\nFrom\r\nLibrary" + : "FREE\r\n\r\nAdd to\r\nLibrary"; + + public override bool Enabled + { + get => globalEnabled; + protected set + { + if (globalEnabled != value) + { + globalEnabled = value; + ButtonEnabled?.Invoke(null, EventArgs.Empty); + } + } + } + + internal AyceButton(Item item, bool inLibrary) : base(item, inLibrary) + { + ButtonEnabled += DownloadButton_ButtonEnabled; + } + + public override async Task PerformClickAsync(LibraryBook accountBook) + { + if (!Enabled) return; + + Enabled = false; + + try + { + if (InLibrary) + await RemoveFromLibraryAsync(accountBook); + else + await AddToLibraryAsync(accountBook); + } + catch(Exception ex) + { + var addRemove = InLibrary ? "remove" : "add"; + var toFrom = InLibrary ? "from" : "to"; + + Serilog.Log.Logger.Error(ex, $"Failed to {addRemove} {{book}} {toFrom} library", new { Item.ProductId, Item.TitleWithSubtitle }); + } + finally { Enabled = true; } + + } + + private async Task RemoveFromLibraryAsync(LibraryBook accountBook) + { + Api api = await accountBook.GetApiAsync(); + + if (await api.RemoveItemFromLibraryAsync(Item.ProductId)) + { + using var context = DbContexts.GetContext(); + var lb = context.GetLibraryBook_Flat_NoTracking(Item.ProductId); + int result = await Task.Run((new[] { lb }).PermanentlyDeleteBooks); + InLibrary = result == 0; + } + } + + private async Task AddToLibraryAsync(LibraryBook accountBook) + { + Api api = await accountBook.GetApiAsync(); + + if (!await api.AddItemToLibraryAsync(Item.ProductId)) return; + + Item item = null; + + for (int tryCount = 0; tryCount < 5 && item is null; tryCount++) + { + //Wait a half second to allow the server time to update + await Task.Delay(500); + item = await api.GetLibraryBookAsync(Item.ProductId, LibraryOptions.ResponseGroupOptions.ALL_OPTIONS); + } + + if (item is null) return; + + if (item.IsEpisodes) + { + var seriesParent = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true) + .Select(lb => lb.Book) + .FirstOrDefault(b => b.IsEpisodeParent() && b.AudibleProductId.In(item.Relationships.Select((Relationship r) => r.Asin))); + + if (seriesParent is null) return; + + item.Series = new[] + { + new AudibleApi.Common.Series + { + Asin = seriesParent.AudibleProductId, + Sequence = item.Relationships.FirstOrDefault(r => r.Asin == seriesParent.AudibleProductId)?.Sort?.ToString() ?? "0", + Title = seriesParent.Title + } + }; + } + + InLibrary = await LibraryCommands.ImportSingleToDbAsync(item, accountBook.Account, accountBook.Book.Locale) != 0; + } + + private void DownloadButton_ButtonEnabled(object sender, EventArgs e) + => OnPropertyChanged(nameof(Enabled)); + + public override int CompareTo(object ob) + { + if (ob is not AyceButton other) return 1; + return other.InLibrary.CompareTo(InLibrary); + } + + ~AyceButton() + { + ButtonEnabled -= DownloadButton_ButtonEnabled; + } + } +} diff --git a/Source/LibationUiBase/SeriesView/SeriesButton.cs b/Source/LibationUiBase/SeriesView/SeriesButton.cs new file mode 100644 index 00000000..3156c036 --- /dev/null +++ b/Source/LibationUiBase/SeriesView/SeriesButton.cs @@ -0,0 +1,51 @@ +using AudibleApi.Common; +using DataLayer; +using Dinah.Core.Threading; +using System; +using System.ComponentModel; +using System.Threading.Tasks; + +namespace LibationUiBase.SeriesView +{ + /// + /// base view model for the Series Viewer 'Availability' button column + /// + public abstract class SeriesButton : SynchronizeInvoker, IComparable, INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + private bool inLibrary; + + protected Item Item { get; } + public abstract string DisplayText { get; } + public abstract bool HasButtonAction { get; } + public abstract bool Enabled { get; protected set; } + public bool InLibrary + { + get => inLibrary; + protected set + { + if (inLibrary != value) + { + inLibrary = value; + OnPropertyChanged(nameof(InLibrary)); + OnPropertyChanged(nameof(DisplayText)); + } + } + } + + protected SeriesButton(Item item, bool inLibrary) + { + Item = item; + this.inLibrary = inLibrary; + } + + public abstract Task PerformClickAsync(LibraryBook accountBook); + + protected void OnPropertyChanged(string propertyName) + => Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); + + public override string ToString() => DisplayText; + + public abstract int CompareTo(object ob); + } +} diff --git a/Source/LibationUiBase/SeriesView/SeriesEntry.cs b/Source/LibationUiBase/SeriesView/SeriesEntry.cs new file mode 100644 index 00000000..44b451e4 --- /dev/null +++ b/Source/LibationUiBase/SeriesView/SeriesEntry.cs @@ -0,0 +1,151 @@ +using ApplicationServices; +using AudibleApi; +using AudibleApi.Common; +using AudibleUtilities; +using DataLayer; +using Dinah.Core; +using Dinah.Core.Threading; +using FileLiberator; +using LibationFileManager; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace LibationUiBase.SeriesView +{ + public class SeriesItem : SynchronizeInvoker, INotifyPropertyChanged + { + public object Cover { get; private set; } + public SeriesOrder Order { get; } + public string Title => Item.TitleWithSubtitle; + public SeriesButton Button { get; } + public Item Item { get; } + + public event PropertyChangedEventHandler PropertyChanged; + + private SeriesItem(Item item, string order, bool inLibrary, bool inWishList) + { + Item = item; + Order = new SeriesOrder(order); + Button = Item.Plans.Any(p => p.IsAyce) ? new AyceButton(item, inLibrary) : new WishlistButton(item, inLibrary, inWishList); + LoadCover(item.PictureId); + Button.PropertyChanged += DownloadButton_PropertyChanged; + } + + public void ViewOnAudible(string localeString) + { + var locale = Localization.Get(localeString); + var link = $"https://www.audible.{locale.TopDomain}/pd/{Item.ProductId}"; + Go.To.Url(link); + } + + private void DownloadButton_PropertyChanged(object sender, PropertyChangedEventArgs e) + => OnPropertyChanged(nameof(Button)); + + private void OnPropertyChanged(string propertyName) + => Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); + + private void LoadCover(string pictureId) + { + var (isDefault, picture) = PictureStorage.GetPicture(new PictureDefinition(pictureId, PictureSize._80x80)); + if (isDefault) + { + PictureStorage.PictureCached += PictureStorage_PictureCached; + } + Cover = BaseUtil.LoadImage(picture, PictureSize._80x80); + } + + private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) + { + if (e?.Definition.PictureId != null && Item?.PictureId != null) + { + byte[] picture = e.Picture; + if ((picture == null || picture.Length != 0) && e.Definition.PictureId == Item.PictureId) + { + Cover = BaseUtil.LoadImage(e.Picture, PictureSize._80x80); + PictureStorage.PictureCached -= PictureStorage_PictureCached; + OnPropertyChanged(nameof(Cover)); + } + } + } + + public static async Task>> GetAllSeriesItemsAsync(LibraryBook libraryBook) + { + var api = await libraryBook.GetApiAsync(); + + //Get Item for each series that this book belong to + var seriesItemsTask = api.GetCatalogProductsAsync(libraryBook.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId), CatalogOptions.ResponseGroupOptions.Media | CatalogOptions.ResponseGroupOptions.Relationships); + + using var semaphore = new SemaphoreSlim(10); + + //Start getting the wishlist in the background + var wishlistTask = api.GetWishListProductsAsync( + new WishListOptions + { + PageNumber = 0, + NumberOfResultPerPage = 50, + ResponseGroups = WishListOptions.ResponseGroupOptions.None + }, + numItemsPerRequest: 50, + semaphore); + + var items = new Dictionary>(); + + //Get all children of all series + foreach (var series in await seriesItemsTask) + { + //Books that are part of series have RelationshipType.Series + //Podcast episodes have RelationshipType.Episode + var childrenAsins = series.Relationships + .Where(r => r.RelationshipType is RelationshipType.Series or RelationshipType.Episode && r.RelationshipToProduct is RelationshipToProduct.Child) + .Select(r => r.Asin) + .ToList(); + + if (childrenAsins.Count > 0) + { + var children = await api.GetCatalogProductsAsync(childrenAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS, 50, semaphore); + + //If the price is null, this item is not available to the user + var childrenWithPrices = children.Where(p => p.Price != null).ToList(); + + if (childrenWithPrices.Count > 0) + items[series] = childrenWithPrices; + } + } + + //Await the wishlist asins + var wishlistAsins = (await wishlistTask).Select(w => w.Asin).ToHashSet(); + + var fullLib = DbContexts.GetLibrary_Flat_NoTracking(); + var seriesEntries = new Dictionary>(); + + //Create a SeriesItem liste for each series. + foreach (var series in items.Keys) + { + ApiExtended.SetSeries(series, items[series]); + + seriesEntries[series] = new List(); + + foreach (var item in items[series].Where(i => !string.IsNullOrEmpty(i.PictureId))) + { + var order = item.Series.Single(s => s.Asin == series.Asin).Sequence; + //Match the account/book in the database + var inLibrary = fullLib.Any(lb => lb.Account == libraryBook.Account && lb.Book.AudibleProductId == item.ProductId && !lb.AbsentFromLastScan); + var inWishList = wishlistAsins.Contains(item.Asin); + + seriesEntries[series].Add(new SeriesItem(item, order, inLibrary, inWishList)); + } + } + + return seriesEntries; + } + + ~SeriesItem() + { + PictureStorage.PictureCached -= PictureStorage_PictureCached; + Button.PropertyChanged -= DownloadButton_PropertyChanged; + } + } +} diff --git a/Source/LibationUiBase/SeriesView/SeriesOrder.cs b/Source/LibationUiBase/SeriesView/SeriesOrder.cs new file mode 100644 index 00000000..ad09dc5f --- /dev/null +++ b/Source/LibationUiBase/SeriesView/SeriesOrder.cs @@ -0,0 +1,23 @@ +using System; + +namespace LibationUiBase.SeriesView +{ + public class SeriesOrder : IComparable + { + public float Order { get; } + public string OrderString { get; } + + public SeriesOrder(string orderString) + { + OrderString = orderString; + Order = float.TryParse(orderString, out var o) ? o : -1f; + } + public override string ToString() => OrderString; + + public int CompareTo(object obj) + { + if (obj is not SeriesOrder other) return 1; + return Order.CompareTo(other.Order); + } + } +} diff --git a/Source/LibationUiBase/SeriesView/WishlistButton.cs b/Source/LibationUiBase/SeriesView/WishlistButton.cs new file mode 100644 index 00000000..fcf4bcb8 --- /dev/null +++ b/Source/LibationUiBase/SeriesView/WishlistButton.cs @@ -0,0 +1,93 @@ +using AudibleApi; +using AudibleApi.Common; +using DataLayer; +using FileLiberator; +using System; +using System.Threading.Tasks; + +namespace LibationUiBase.SeriesView +{ + internal class WishlistButton : SeriesButton + { + private bool instanceEnabled = true; + + private bool inWishList; + + public override bool HasButtonAction => !InLibrary; + public override string DisplayText + => InLibrary ? "Already\r\nOwned" + : InWishList ? "Remove\r\nFrom\r\nWishlist" + : "Add to\r\nWishlist"; + + public override bool Enabled + { + get => instanceEnabled; + protected set + { + if (instanceEnabled != value) + { + instanceEnabled = value; + OnPropertyChanged(nameof(Enabled)); + } + } + } + + private bool InWishList + { + get => inWishList; + set + { + if (inWishList != value) + { + inWishList = value; + OnPropertyChanged(nameof(InWishList)); + OnPropertyChanged(nameof(DisplayText)); + } + } + } + + internal WishlistButton(Item item, bool inLibrary, bool inWishList) : base(item, inLibrary) + { + this.inWishList = inWishList; + } + + public override async Task PerformClickAsync(LibraryBook accountBook) + { + if (!Enabled || !HasButtonAction) return; + + Enabled = false; + + try + { + Api api = await accountBook.GetApiAsync(); + + if (InWishList) + { + await api.DeleteFromWishListAsync(Item.Asin); + InWishList = false; + } + else + { + await api.AddToWishListAsync(Item.Asin); + InWishList = true; + } + } + catch (Exception ex) + { + var addRemove = InWishList ? "remove" : "add"; + var toFrom = InWishList ? "from" : "to"; + + Serilog.Log.Logger.Error(ex, $"Failed to {addRemove} {{book}} {toFrom} wish list", new { Item.ProductId, Item.TitleWithSubtitle }); + } + finally { Enabled = true; } + } + + public override int CompareTo(object ob) + { + if (ob is not WishlistButton other) return -1; + + int libcmp = other.InLibrary.CompareTo(InLibrary); + return (libcmp == 0) ? other.InWishList.CompareTo(InWishList) : libcmp; + } + } +} diff --git a/Source/LibationWinForms/Form1._NonUI.cs b/Source/LibationWinForms/Form1._NonUI.cs index e17a81a3..21ddc9f3 100644 --- a/Source/LibationWinForms/Form1._NonUI.cs +++ b/Source/LibationWinForms/Form1._NonUI.cs @@ -6,6 +6,7 @@ using ApplicationServices; using Dinah.Core.WindowsDesktop.Drawing; using LibationFileManager; +using LibationUiBase; namespace LibationWinForms { @@ -20,6 +21,8 @@ private void Configure_NonUI() PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format)); PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format)); + BaseUtil.SetLoadImageDelegate(WinFormsUtil.TryLoadImageOrDefault); + // wire-up event to automatically download after scan. // winforms only. this should NOT be allowed in cli updateCountsBw.RunWorkerCompleted += (object sender, System.ComponentModel.RunWorkerCompletedEventArgs e) => diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs index 879d2f62..248a5afb 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs @@ -41,7 +41,6 @@ private void InitializeComponent() productsGrid.TabIndex = 0; productsGrid.VisibleCountChanged += productsGrid_VisibleCountChanged; productsGrid.LiberateClicked += productsGrid_LiberateClicked; - productsGrid.ConvertToMp3Clicked += productsGrid_ConvertToMp3Clicked; productsGrid.CoverClicked += productsGrid_CoverClicked; productsGrid.DetailsClicked += productsGrid_DetailsClicked; productsGrid.DescriptionClicked += productsGrid_DescriptionClicked; diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index c4659d81..2ad49a05 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -5,6 +5,7 @@ using LibationFileManager; using LibationUiBase.GridView; using LibationWinForms.Dialogs; +using LibationWinForms.SeriesView; using System; using System.Collections.Generic; using System.Drawing; @@ -218,6 +219,20 @@ private void productsGrid_CellContextMenuStripNeeded(IGridEntry entry, ContextMe bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this); } + #endregion + #region View All Series + + if (entry.Book.SeriesLink.Any()) + { + var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series"; + + var viewSeriesMenuItem = new ToolStripMenuItem { Text = header }; + + ctxMenu.Items.Add(viewSeriesMenuItem); + + viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show(); + } + #endregion } @@ -333,12 +348,6 @@ private void productsGrid_LiberateClicked(ILibraryBookEntry liveGridEntry) LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook); } - private void productsGrid_ConvertToMp3Clicked(ILibraryBookEntry liveGridEntry) - { - if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error) - ConvertToMp3Clicked?.Invoke(this, liveGridEntry.LibraryBook); - } - private void productsGrid_RemovableCountChanged(object sender, EventArgs e) { RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is true)); diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 0c09e8fc..54a82439 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -21,7 +21,6 @@ public partial class ProductsGrid : UserControl /// Number of visible rows has changed public event EventHandler VisibleCountChanged; public event LibraryBookEntryClickedEventHandler LiberateClicked; - public event LibraryBookEntryClickedEventHandler ConvertToMp3Clicked; public event GridEntryClickedEventHandler CoverClicked; public event LibraryBookEntryClickedEventHandler DetailsClicked; public event GridEntryRectangleClickedEventHandler DescriptionClicked; @@ -308,7 +307,8 @@ private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry exist //Add episode to the grid beneath the parent int seriesIndex = bindingList.IndexOf(seriesEntry); - bindingList.Insert(seriesIndex + 1, episodeEntry); + int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry); + bindingList.Insert(seriesIndex + 1 + episodeIndex, episodeEntry); if (seriesEntry.Liberate.Expanded) bindingList.ExpandItem(seriesEntry); diff --git a/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs b/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs new file mode 100644 index 00000000..7936c895 --- /dev/null +++ b/Source/LibationWinForms/SeriesView/DownloadButtonColumn.cs @@ -0,0 +1,49 @@ +using System.Drawing; +using System.Windows.Forms; +using System.Windows.Forms.VisualStyles; +using LibationUiBase.SeriesView; + +namespace LibationWinForms.SeriesView +{ + public class DownloadButtonColumn : DataGridViewButtonColumn + { + public DownloadButtonColumn() + { + CellTemplate = new DownloadButtonColumnCell(); + CellTemplate.Style.WrapMode = DataGridViewTriState.True; + } + } + internal class DownloadButtonColumnCell : DataGridViewButtonCell + { + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + { + if (value is SeriesButton sentry) + { + string cellValue = sentry.DisplayText; + if (!sentry.Enabled) + { + //Draw disabled button + Rectangle buttonArea = cellBounds; + Rectangle buttonAdjustment = BorderWidths(advancedBorderStyle); + buttonArea.X += buttonAdjustment.X; + buttonArea.Y += buttonAdjustment.Y; + buttonArea.Height -= buttonAdjustment.Height; + buttonArea.Width -= buttonAdjustment.Width; + ButtonRenderer.DrawButton(graphics, buttonArea, cellValue, cellStyle.Font, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak, focused: false, PushButtonState.Disabled); + + } + else if (sentry.HasButtonAction) + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, cellValue, cellValue, errorText, cellStyle, advancedBorderStyle, paintParts); + else + { + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); + TextRenderer.DrawText(graphics, cellValue, cellStyle.Font, cellBounds, cellStyle.ForeColor); + } + } + else + { + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); + } + } + } +} diff --git a/Source/LibationWinForms/SeriesView/SeriesEntryBindingList.cs b/Source/LibationWinForms/SeriesView/SeriesEntryBindingList.cs new file mode 100644 index 00000000..0c07e8e7 --- /dev/null +++ b/Source/LibationWinForms/SeriesView/SeriesEntryBindingList.cs @@ -0,0 +1,46 @@ +using LibationUiBase.SeriesView; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace LibationWinForms.SeriesView +{ + internal class SeriesEntryBindingList : BindingList + { + private PropertyDescriptor _propertyDescriptor; + + private ListSortDirection _listSortDirection; + + private bool _isSortedCore; + + protected override PropertyDescriptor SortPropertyCore => _propertyDescriptor; + + protected override ListSortDirection SortDirectionCore => _listSortDirection; + + protected override bool IsSortedCore => _isSortedCore; + + protected override bool SupportsSortingCore => true; + + public SeriesEntryBindingList() : base(new List()) { } + public SeriesEntryBindingList(IEnumerable entries) : base(entries.ToList()) { } + + protected override void ApplySortCore(PropertyDescriptor prop, ListSortDirection direction) + { + var itemsList = (List)base.Items; + + var sorted + = (direction == ListSortDirection.Ascending) + ? itemsList.OrderBy(prop.GetValue).ToList() + : itemsList.OrderByDescending(prop.GetValue).ToList(); + + itemsList.Clear(); + itemsList.AddRange(sorted); + + _propertyDescriptor = prop; + _listSortDirection = direction; + _isSortedCore = true; + + OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1)); + } + } +} diff --git a/Source/LibationWinForms/SeriesView/SeriesViewDialog.Designer.cs b/Source/LibationWinForms/SeriesView/SeriesViewDialog.Designer.cs new file mode 100644 index 00000000..21a2c38a --- /dev/null +++ b/Source/LibationWinForms/SeriesView/SeriesViewDialog.Designer.cs @@ -0,0 +1,62 @@ +using System.Windows.Forms; + +namespace LibationWinForms.SeriesView +{ + partial class SeriesViewDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + tabControl1 = new TabControl(); + SuspendLayout(); + // + // tabControl1 + // + tabControl1.Dock = DockStyle.Fill; + tabControl1.Location = new System.Drawing.Point(0, 0); + tabControl1.Name = "tabControl1"; + tabControl1.SelectedIndex = 0; + tabControl1.Size = new System.Drawing.Size(800, 450); + tabControl1.TabIndex = 0; + // + // SeriesViewDialog + // + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new System.Drawing.Size(800, 450); + Controls.Add(tabControl1); + FormBorderStyle = FormBorderStyle.SizableToolWindow; + Name = "SeriesViewDialog"; + StartPosition = FormStartPosition.CenterParent; + Text = "View All Items in Series"; + ResumeLayout(false); + } + + private TabControl tabControl1; + + #endregion + } +} \ No newline at end of file diff --git a/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs b/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs new file mode 100644 index 00000000..aac9a704 --- /dev/null +++ b/Source/LibationWinForms/SeriesView/SeriesViewDialog.cs @@ -0,0 +1,193 @@ +using AudibleApi.Common; +using DataLayer; +using Dinah.Core; +using System.ComponentModel; +using System.Windows.Forms; +using System; +using Dinah.Core.WindowsDesktop.Forms; +using LibationWinForms.GridView; +using LibationFileManager; +using LibationUiBase.SeriesView; +using System.Drawing; + +namespace LibationWinForms.SeriesView +{ + public partial class SeriesViewDialog : Form + { + private readonly LibraryBook LibraryBook; + + public SeriesViewDialog() + { + InitializeComponent(); + this.RestoreSizeAndLocation(Configuration.Instance); + this.SetLibationIcon(); + + Load += SeriesViewDialog_Load; + FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); + } + + public SeriesViewDialog(LibraryBook libraryBook) : this() + { + LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, "libraryBook"); + } + + private async void SeriesViewDialog_Load(object sender, EventArgs e) + { + try + { + var seriesEntries = await SeriesItem.GetAllSeriesItemsAsync(LibraryBook); + + //Create a DataGridView for each series and add all children of that series to it. + foreach (var series in seriesEntries.Keys) + { + var dgv = createNewSeriesGrid(); + dgv.CellContentClick += Dgv_CellContentClick; + dgv.DataSource = new SeriesEntryBindingList(seriesEntries[series]); + dgv.BindingContextChanged += (_, _) => dgv.Sort(dgv.Columns["Order"], ListSortDirection.Ascending); + + var tab = new TabPage { Text = series.Title }; + tab.Controls.Add(dgv); + tab.VisibleChanged += (_, _) => dgv.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.AllCells); + tabControl1.Controls.Add(tab); + } + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error loading searies info"); + + var tab = new TabPage { Text = "ERROR" }; + tab.Controls.Add(new Label { Text = "ERROR LOADING SERIES INFO\r\n\r\n" + ex.Message, ForeColor = Color.Red, Dock = DockStyle.Fill }); + tabControl1.Controls.Add(tab); + } + } + + private ImageDisplay imageDisplay; + + private async void Dgv_CellContentClick(object sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex < 0) return; + + var dgv = (DataGridView)sender; + var sentry = dgv.GetBoundItem(e.RowIndex); + + if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Cover)) + { + coverClicked(sentry.Item); + return; + } + else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Title)) + { + sentry.ViewOnAudible(LibraryBook.Book.Locale); + return; + } + else if (dgv.Columns[e.ColumnIndex].DataPropertyName == nameof(SeriesItem.Button) && sentry.Button.HasButtonAction) + { + await sentry.Button.PerformClickAsync(LibraryBook); + } + } + + private void coverClicked(Item libraryBook) + { + var picDef = new PictureDefinition(libraryBook.PictureLarge ?? libraryBook.PictureId, PictureSize.Native); + + void PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == picDef.PictureId) + imageDisplay.SetCoverArt(e.Picture); + + PictureStorage.PictureCached -= PictureCached; + } + + PictureStorage.PictureCached += PictureCached; + (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); + + var windowTitle = $"{libraryBook.Title} - Cover"; + + if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) + { + imageDisplay = new ImageDisplay(); + imageDisplay.RestoreSizeAndLocation(Configuration.Instance); + imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); + } + + imageDisplay.Text = windowTitle; + imageDisplay.SetCoverArt(initialImageBts); + if (!isDefault) + PictureStorage.PictureCached -= PictureCached; + + if (!imageDisplay.Visible) + imageDisplay.Show(); + } + + private static DataGridView createNewSeriesGrid() + { + var dgv = new DataGridView + { + Dock = DockStyle.Fill, + RowHeadersVisible = false, + ReadOnly = false, + ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + AllowUserToResizeRows = false, + AutoGenerateColumns = false + }; + + dgv.RowTemplate.Height = 80; + + dgv.Columns.Add(new DataGridViewImageColumn + { + DataPropertyName = nameof(SeriesItem.Cover), + HeaderText = "Cover", + Name = "Cover", + ReadOnly = true, + Resizable = DataGridViewTriState.False, + Width = 80 + }); + dgv.Columns.Add(new DataGridViewTextBoxColumn + { + DataPropertyName = nameof(SeriesItem.Order), + HeaderText = "Series\r\nOrder", + Name = "Order", + ReadOnly = true, + SortMode = DataGridViewColumnSortMode.Automatic, + Width = 50 + }); + dgv.Columns.Add(new DownloadButtonColumn + { + DataPropertyName = nameof(SeriesItem.Button), + HeaderText = "Availability", + Name = "DownloadButton", + ReadOnly = true, + SortMode = DataGridViewColumnSortMode.Automatic, + Width = 50 + }); + dgv.Columns.Add(new DataGridViewLinkColumn + { + DataPropertyName = nameof(SeriesItem.Title), + HeaderText = "Title", + Name = "Title", + ReadOnly = true, + TrackVisitedState = true, + SortMode = DataGridViewColumnSortMode.Automatic, + Width = 200, + }); + + dgv.CellToolTipTextNeeded += Dgv_CellToolTipTextNeeded; + + return dgv; + } + + private static void Dgv_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e) + { + if (sender is not DataGridView dgv || e.ColumnIndex < 0) return; + + e.ToolTipText = dgv.Columns[e.ColumnIndex].DataPropertyName switch + { + nameof(SeriesItem.Cover) => "Click to see full size", + nameof(SeriesItem.Title) => "Open Audible product page", + _ => string.Empty + }; + } + } +} diff --git a/Source/LibationWinForms/SeriesView/SeriesViewDialog.resx b/Source/LibationWinForms/SeriesView/SeriesViewDialog.resx new file mode 100644 index 00000000..f298a7be --- /dev/null +++ b/Source/LibationWinForms/SeriesView/SeriesViewDialog.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file From e7eac7bed33be949e0de1be5962fa6ba4a324ba1 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Wed, 22 Mar 2023 09:31:44 -0600 Subject: [PATCH 09/16] Log DTO items even if validation fails --- Source/ApplicationServices/LibraryCommands.cs | 46 +++++++++++++------ Source/AudibleUtilities/ApiExtended.cs | 22 ++------- .../AudibleUtilities/AudibleApiValidators.cs | 11 +++++ .../ImportValidationException.cs | 15 ++++++ 4 files changed, 62 insertions(+), 32 deletions(-) create mode 100644 Source/AudibleUtilities/ImportValidationException.cs diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 0bc9715c..f0c6e881 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -12,6 +12,7 @@ using FileManager; using LibationFileManager; using Newtonsoft.Json.Linq; +using NPOI.OpenXmlFormats.Spreadsheet; using Serilog; using static DtoImporterService.PerfLogger; @@ -261,26 +262,43 @@ private static async Task> scanAccountAsync(ApiExtended apiExte logTime($"pre scanAccountAsync {account.AccountName}"); - var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes); + try + { + var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes); + + logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}"); + + await logDtoItemsAsync(dtoItems); - if (archiver is not null) + return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList(); + } + catch(ImportValidationException ex) + { + await logDtoItemsAsync(ex.Items, ex.InnerExceptions.ToArray()); + throw; + } + + async Task logDtoItemsAsync(IEnumerable dtoItems, IEnumerable exceptions = null) { - var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json"; - var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson))); + if (archiver is not null) + { + var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json"; + var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson))); - var scanFile = new JObject - { - { "Account", account.MaskedLogEntry }, - { "ScannedDateTime", DateTime.Now.ToString("u") }, - { "Items", items} - }; + var scanFile = new JObject + { + { "Account", account.MaskedLogEntry }, + { "ScannedDateTime", DateTime.Now.ToString("u") }, + }; - await archiver.AddFileAsync(fileName, scanFile); - } + if (exceptions?.Any() is true) + scanFile.Add("Exceptions", JArray.FromObject(exceptions)); - logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}"); + scanFile.Add("Items", items); - return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList(); + await archiver.AddFileAsync(fileName, scanFile); + } + } } private static async Task importIntoDbAsync(List importItems) diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 2c77a971..9edecd6b 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -157,25 +157,11 @@ private async Task> getItemsAsync(LibraryOptions libraryOptions, bool Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds); Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms."); - var validators = new List(); - validators.AddRange(getValidators()); - foreach (var v in validators) - { - var exceptions = v.Validate(items); - if (exceptions is not null && exceptions.Any()) - throw new AggregateException(exceptions); - } - return items; - } + var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)); + if (allExceptions?.Any() is true) + throw new ImportValidationException(items, allExceptions); - private static List getValidators() - { - var type = typeof(IValidator); - var types = AppDomain.CurrentDomain.GetAssemblies() - .SelectMany(s => s.GetTypes()) - .Where(p => type.IsAssignableFrom(p) && !p.IsInterface); - - return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList(); + return items; } #region episodes and podcasts diff --git a/Source/AudibleUtilities/AudibleApiValidators.cs b/Source/AudibleUtilities/AudibleApiValidators.cs index cae6e812..6ae9269a 100644 --- a/Source/AudibleUtilities/AudibleApiValidators.cs +++ b/Source/AudibleUtilities/AudibleApiValidators.cs @@ -8,7 +8,18 @@ namespace AudibleUtilities public interface IValidator { IEnumerable Validate(IEnumerable items); + + public static IValidator[] GetAllValidators() + => new IValidator[] + { + new LibraryValidator(), + new BookValidator(), + new CategoryValidator(), + new ContributorValidator(), + new SeriesValidator(), + }; } + public class LibraryValidator : IValidator { public IEnumerable Validate(IEnumerable items) diff --git a/Source/AudibleUtilities/ImportValidationException.cs b/Source/AudibleUtilities/ImportValidationException.cs new file mode 100644 index 00000000..66db8574 --- /dev/null +++ b/Source/AudibleUtilities/ImportValidationException.cs @@ -0,0 +1,15 @@ +using AudibleApi.Common; +using System; +using System.Collections.Generic; + +namespace AudibleUtilities +{ + public class ImportValidationException : AggregateException + { + public List Items { get; } + public ImportValidationException(List items, IEnumerable exceptions) : base(exceptions) + { + Items = items; + } + } +} From 1783da3e2d59d335229950f7839ebae5bce63645 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Wed, 22 Mar 2023 11:01:20 -0600 Subject: [PATCH 10/16] Ensure series and episode DateAdded is never default (#543) --- Source/AudibleUtilities/ApiExtended.cs | 13 ++++++++++++- .../SeriesView/{SeriesEntry.cs => SeriesItem.cs} | 0 2 files changed, 12 insertions(+), 1 deletion(-) rename Source/LibationUiBase/SeriesView/{SeriesEntry.cs => SeriesItem.cs} (100%) diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 9edecd6b..97caa4aa 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -220,6 +220,9 @@ private async Task> getProductsAsync(int batchNum, List asins public static void SetSeries(Item parent, IEnumerable children) { + ArgumentValidator.EnsureNotNull(parent, nameof(parent)); + ArgumentValidator.EnsureNotNull(children, nameof(children)); + //A series parent will always have exactly 1 Series parent.Series = new[] { @@ -232,7 +235,15 @@ public static void SetSeries(Item parent, IEnumerable children) }; if (parent.PurchaseDate == default) - parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().First(); + { + parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().FirstOrDefault(d => d != default); + + if (parent.PurchaseDate == default) + { + Serilog.Log.Logger.Warning("{series} doesn't have a purchase date. Using UtcNow", parent); + parent.PurchaseDate = DateTimeOffset.UtcNow; + } + } foreach (var child in children) { diff --git a/Source/LibationUiBase/SeriesView/SeriesEntry.cs b/Source/LibationUiBase/SeriesView/SeriesItem.cs similarity index 100% rename from Source/LibationUiBase/SeriesView/SeriesEntry.cs rename to Source/LibationUiBase/SeriesView/SeriesItem.cs From ed6f741a659f340c22759e97b1417136d0a219ac Mon Sep 17 00:00:00 2001 From: Mbucari Date: Wed, 22 Mar 2023 11:46:11 -0600 Subject: [PATCH 11/16] Fix SettingsFileIsValid --- Source/LibationFileManager/Configuration.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Source/LibationFileManager/Configuration.cs b/Source/LibationFileManager/Configuration.cs index b1d6c671..74d95c86 100644 --- a/Source/LibationFileManager/Configuration.cs +++ b/Source/LibationFileManager/Configuration.cs @@ -19,8 +19,13 @@ public static bool SettingsFileIsValid(string settingsFile) var pDic = new PersistentDictionary(settingsFile, isReadOnly: false); var booksDir = pDic.GetString(nameof(Books)); - if (booksDir is null || !Directory.Exists(booksDir)) + + if (booksDir is null) return false; + + if (!Directory.Exists(booksDir)) { + //"Books" is not null, so setup has already been run. + //Since Books can't be found, try to create it in Libation settings folder booksDir = Path.Combine(Path.GetDirectoryName(settingsFile), nameof(Books)); try { From 7289459170b33774781200e298d8c034d996c227 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Wed, 22 Mar 2023 13:10:32 -0600 Subject: [PATCH 12/16] Migrate to Avalonia 11.0.0-preview6 --- Source/HangoverAvalonia/App.axaml | 2 +- .../HangoverAvalonia/HangoverAvalonia.csproj | 12 +- Source/HangoverAvalonia/ViewLocator.cs | 2 +- Source/HangoverBase/DatabaseTab.cs | 3 +- Source/LibationAvalonia/App.axaml | 7 +- .../Assets/DataGridTheme.xaml | 658 ------------------ Source/LibationAvalonia/AvaloniaUtils.cs | 6 +- .../Controls/DataGridCheckBoxColumnExt.cs | 2 +- .../Controls/DataGridMyRatingColumn.cs | 8 +- .../Controls/DataGridTemplateColumnExt.cs | 2 +- .../DirectoryOrCustomSelectControl.axaml.cs | 12 +- .../Controls/DirectorySelectControl.axaml.cs | 5 - .../Controls/GroupBox.axaml.cs | 5 - .../Controls/LinkLabel.axaml.cs | 5 - .../Controls/WheelComboBox.axaml.cs | 5 - .../Dialogs/AccountsDialog.axaml.cs | 26 +- .../Dialogs/BookDetailsDialog.axaml.cs | 5 - .../Dialogs/BookRecordsDialog.axaml.cs | 12 +- .../Dialogs/DescriptionDisplayDialog.axaml.cs | 6 - .../Dialogs/EditReplacementChars.axaml.cs | 5 - .../Dialogs/EditTemplateDialog.axaml | 1 - .../Dialogs/EditTemplateDialog.axaml.cs | 3 +- .../Dialogs/ImageDisplayDialog.axaml.cs | 16 +- .../Dialogs/LibationFilesDialog.axaml.cs | 5 - .../LiberatedStatusBatchAutoDialog.axaml.cs | 4 - .../LiberatedStatusBatchManualDialog.axaml.cs | 4 - .../Dialogs/LocateAudiobooksDialog.axaml.cs | 8 +- .../Login/ApprovalNeededDialog.axaml.cs | 5 - .../Dialogs/Login/CaptchaDialog.axaml.cs | 5 - .../Login/LoginCallbackDialog.axaml.cs | 6 - .../Login/LoginChoiceEagerDialog.axaml.cs | 5 - .../Login/LoginExternalDialog.axaml.cs | 4 - .../Dialogs/Login/MfaDialog.axaml.cs | 5 - .../MessageBoxAlertAdminDialog.axaml.cs | 5 - .../Dialogs/MessageBoxWindow.axaml.cs | 5 - .../Dialogs/ScanAccountsDialog.axaml.cs | 11 +- .../Dialogs/SearchSyntaxDialog.axaml.cs | 9 +- .../Dialogs/SettingsDialog.axaml.cs | 5 - .../Dialogs/SetupDialog.axaml.cs | 5 - .../Dialogs/TagsBatchDialog.axaml.cs | 5 - .../LibationAvalonia/LibationAvalonia.csproj | 16 +- Source/LibationAvalonia/ViewLocator.cs | 2 +- .../Views/MainWindow.Export.cs | 16 +- .../Views/MainWindow.axaml.cs | 15 - .../Views/ProcessBookControl.axaml.cs | 5 - .../Views/ProcessQueueControl.axaml.cs | 5 - .../Views/ProductsDisplay.axaml | 3 +- .../Views/ProductsDisplay.axaml.cs | 24 +- .../Views/SeriesViewGrid.axaml | 5 + 49 files changed, 90 insertions(+), 905 deletions(-) delete mode 100644 Source/LibationAvalonia/Assets/DataGridTheme.xaml diff --git a/Source/HangoverAvalonia/App.axaml b/Source/HangoverAvalonia/App.axaml index f4f24946..0534c7e8 100644 --- a/Source/HangoverAvalonia/App.axaml +++ b/Source/HangoverAvalonia/App.axaml @@ -7,6 +7,6 @@ - + diff --git a/Source/HangoverAvalonia/HangoverAvalonia.csproj b/Source/HangoverAvalonia/HangoverAvalonia.csproj index af95f39e..fb32461e 100644 --- a/Source/HangoverAvalonia/HangoverAvalonia.csproj +++ b/Source/HangoverAvalonia/HangoverAvalonia.csproj @@ -66,13 +66,13 @@ - - + + - - - - + + + + diff --git a/Source/HangoverAvalonia/ViewLocator.cs b/Source/HangoverAvalonia/ViewLocator.cs index 44467ea7..8756c78d 100644 --- a/Source/HangoverAvalonia/ViewLocator.cs +++ b/Source/HangoverAvalonia/ViewLocator.cs @@ -7,7 +7,7 @@ namespace HangoverAvalonia { public class ViewLocator : IDataTemplate { - public IControl Build(object data) + public Control Build(object data) { var name = data.GetType().FullName!.Replace("ViewModel", "View"); var type = Type.GetType(name); diff --git a/Source/HangoverBase/DatabaseTab.cs b/Source/HangoverBase/DatabaseTab.cs index b9bcc88d..6c58b0f3 100644 --- a/Source/HangoverBase/DatabaseTab.cs +++ b/Source/HangoverBase/DatabaseTab.cs @@ -64,7 +64,8 @@ public void ExecuteQuery() try { - var sql = _commands.SqlInput().Trim(); + var sql = _commands.SqlInput()?.Trim(); + if (sql is null) return; #region // explanation // Routing statements to non-query is a convenience. diff --git a/Source/LibationAvalonia/App.axaml b/Source/LibationAvalonia/App.axaml index 9e8d399d..e76d03e1 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -8,10 +8,9 @@ - - - - + + + \ No newline at end of file diff --git a/Source/LibationAvalonia/Assets/DataGridTheme.xaml b/Source/LibationAvalonia/Assets/DataGridTheme.xaml deleted file mode 100644 index 904b6a2b..00000000 --- a/Source/LibationAvalonia/Assets/DataGridTheme.xaml +++ /dev/null @@ -1,658 +0,0 @@ - - - 0.6 - 0.8 - 12,0,12,0 - - M1875 1011l-787 787v-1798h-128v1798l-787 -787l-90 90l941 941l941 -941z - M515 93l930 931l-930 931l90 90l1022 -1021l-1022 -1021z - M1939 1581l90 -90l-1005 -1005l-1005 1005l90 90l915 -915z - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Source/LibationAvalonia/AvaloniaUtils.cs b/Source/LibationAvalonia/AvaloniaUtils.cs index 9a1090be..842777cf 100644 --- a/Source/LibationAvalonia/AvaloniaUtils.cs +++ b/Source/LibationAvalonia/AvaloniaUtils.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using Avalonia.Media; using Avalonia.Media.Imaging; +using Avalonia.VisualTree; using LibationAvalonia.Dialogs; using LibationFileManager; using System.Threading.Tasks; @@ -13,7 +14,8 @@ public static IBrush GetBrushFromResources(string name) => GetBrushFromResources(name, Brushes.Transparent); public static IBrush GetBrushFromResources(string name, IBrush defaultBrush) { - if (App.Current.Styles.TryGetResource(name, out var value) && value is IBrush brush) + //TODO: use ThemeVariant + if (App.Current.Styles.TryGetResource(name, null, out var value) && value is IBrush brush) return brush; return defaultBrush; } @@ -21,7 +23,7 @@ public static IBrush GetBrushFromResources(string name, IBrush defaultBrush) public static Task ShowDialogAsync(this DialogWindow dialogWindow, Window owner = null) => dialogWindow.ShowDialog(owner ?? App.MainWindow); - public static Window GetParentWindow(this IControl control) => control.VisualRoot as Window; + public static Window GetParentWindow(this Control control) => control.GetVisualRoot() as Window; private static Bitmap defaultImage; diff --git a/Source/LibationAvalonia/Controls/DataGridCheckBoxColumnExt.cs b/Source/LibationAvalonia/Controls/DataGridCheckBoxColumnExt.cs index de44df9a..eca89804 100644 --- a/Source/LibationAvalonia/Controls/DataGridCheckBoxColumnExt.cs +++ b/Source/LibationAvalonia/Controls/DataGridCheckBoxColumnExt.cs @@ -5,7 +5,7 @@ namespace LibationAvalonia.Controls { public class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn { - protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem) + protected override Control GenerateEditingElementDirect(DataGridCell cell, object dataItem) { //Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary. var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox; diff --git a/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs b/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs index 5a655ceb..41b2be6f 100644 --- a/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs +++ b/Source/LibationAvalonia/Controls/DataGridMyRatingColumn.cs @@ -18,7 +18,7 @@ public DataGridMyRatingColumn() BindingTarget = MyRatingCellEditor.RatingProperty; } - protected override IControl GenerateElement(DataGridCell cell, object dataItem) + protected override Control GenerateElement(DataGridCell cell, object dataItem) { var myRatingElement = new MyRatingCellEditor { @@ -41,7 +41,7 @@ protected override IControl GenerateElement(DataGridCell cell, object dataItem) return myRatingElement; } - protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem) + protected override Control GenerateEditingElementDirect(DataGridCell cell, object dataItem) { var myRatingElement = new MyRatingCellEditor { @@ -57,12 +57,12 @@ protected override IControl GenerateEditingElementDirect(DataGridCell cell, obje return myRatingElement; } - protected override object PrepareCellForEdit(IControl editingElement, RoutedEventArgs editingEventArgs) + protected override object PrepareCellForEdit(Control editingElement, RoutedEventArgs editingEventArgs) => editingElement is MyRatingCellEditor myRating ? myRating.Rating : DefaultRating; - protected override void CancelCellEdit(IControl editingElement, object uneditedValue) + protected override void CancelCellEdit(Control editingElement, object uneditedValue) { if (editingElement is MyRatingCellEditor myRating) { diff --git a/Source/LibationAvalonia/Controls/DataGridTemplateColumnExt.cs b/Source/LibationAvalonia/Controls/DataGridTemplateColumnExt.cs index bf5d84e3..b1ed35c7 100644 --- a/Source/LibationAvalonia/Controls/DataGridTemplateColumnExt.cs +++ b/Source/LibationAvalonia/Controls/DataGridTemplateColumnExt.cs @@ -6,7 +6,7 @@ namespace LibationAvalonia.Controls { public partial class DataGridTemplateColumnExt : DataGridTemplateColumn { - protected override IControl GenerateElement(DataGridCell cell, object dataItem) + protected override Control GenerateElement(DataGridCell cell, object dataItem) { cell?.AttachContextMenu(); return base.GenerateElement(cell, dataItem); diff --git a/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs b/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs index f5ff3746..338e48e7 100644 --- a/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs @@ -97,12 +97,7 @@ private async void CustomDirBrowseBtn_Click(object sender, Avalonia.Interactivit var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options); - customStates.CustomDir = - selectedFolders - .SingleOrDefault()?. - TryGetUri(out var uri) is true - ? uri.LocalPath - : customStates.CustomDir; + customStates.CustomDir = selectedFolders.SingleOrDefault()?.Path?.LocalPath ?? customStates.CustomDir; } private void CheckStates_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) @@ -180,10 +175,5 @@ private string RemoveSubDirectoryFromPath(string path) return path; } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } } diff --git a/Source/LibationAvalonia/Controls/DirectorySelectControl.axaml.cs b/Source/LibationAvalonia/Controls/DirectorySelectControl.axaml.cs index f9dae279..5cb84725 100644 --- a/Source/LibationAvalonia/Controls/DirectorySelectControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/DirectorySelectControl.axaml.cs @@ -90,10 +90,5 @@ public string SubDirectory get => GetValue(SubDirectoryProperty); set => SetValue(SubDirectoryProperty, value); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } } diff --git a/Source/LibationAvalonia/Controls/GroupBox.axaml.cs b/Source/LibationAvalonia/Controls/GroupBox.axaml.cs index 41bf2e2f..bf9a72b8 100644 --- a/Source/LibationAvalonia/Controls/GroupBox.axaml.cs +++ b/Source/LibationAvalonia/Controls/GroupBox.axaml.cs @@ -29,10 +29,5 @@ public string Label get { return GetValue(LabelProperty); } set { SetValue(LabelProperty, value); } } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } } diff --git a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs index 951c6919..ac31ff40 100644 --- a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs +++ b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs @@ -33,10 +33,5 @@ protected override void OnPointerExited(PointerEventArgs e) this.Cursor = Cursor.Default; base.OnPointerExited(e); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } } diff --git a/Source/LibationAvalonia/Controls/WheelComboBox.axaml.cs b/Source/LibationAvalonia/Controls/WheelComboBox.axaml.cs index 0af4d398..9658ec3e 100644 --- a/Source/LibationAvalonia/Controls/WheelComboBox.axaml.cs +++ b/Source/LibationAvalonia/Controls/WheelComboBox.axaml.cs @@ -26,10 +26,5 @@ protected override void OnPointerWheelChanged(PointerWheelEventArgs e) base.OnPointerWheelChanged(e); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } } diff --git a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs index da450e0d..e2da5d21 100644 --- a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs @@ -129,16 +129,16 @@ public async void ImportButton_Clicked(object sender, Avalonia.Interactivity.Rou string audibleAppDataDir = GetAudibleCliAppDataPath(); if (Directory.Exists(audibleAppDataDir)) - openFileDialogOptions.SuggestedStartLocation = new BclStorageFolder(audibleAppDataDir); + openFileDialogOptions.SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(audibleAppDataDir); var selectedFiles = await StorageProvider.OpenFilePickerAsync(openFileDialogOptions); - var selectedFile = selectedFiles.SingleOrDefault(); + var selectedFile = selectedFiles.SingleOrDefault()?.TryGetLocalPath(); - if (selectedFile?.TryGetUri(out var uri) is not true) return; + if (selectedFile is null) return; try { - var jsonText = File.ReadAllText(uri.LocalPath); + var jsonText = File.ReadAllText(selectedFile); var mkbAuth = Mkb79Auth.FromJson(jsonText); var account = await mkbAuth.ToAccountAsync(); @@ -159,7 +159,7 @@ public async void ImportButton_Clicked(object sender, Avalonia.Interactivity.Rou { await MessageBox.ShowAdminAlert( this, - $"An error occurred while importing an account from:\r\n{uri.LocalPath}\r\n\r\nIs the file encrypted?", + $"An error occurred while importing an account from:\r\n{selectedFile}\r\n\r\nIs the file encrypted?", "Error Importing Account", ex); } @@ -196,12 +196,6 @@ protected override async Task SaveAndCloseAsync() public async void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e) => await SaveAndCloseAsync(); - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - private void persist(AccountsSettings accountsSettings) { var existingAccounts = accountsSettings.Accounts; @@ -293,20 +287,20 @@ private async void Export(AccountDto acc) string audibleAppDataDir = GetAudibleCliAppDataPath(); if (Directory.Exists(audibleAppDataDir)) - options.SuggestedStartLocation = new BclStorageFolder(audibleAppDataDir); + options.SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(audibleAppDataDir); - var selectedFile = await StorageProvider.SaveFilePickerAsync(options); + var selectedFile = (await StorageProvider.SaveFilePickerAsync(options))?.TryGetLocalPath(); - if (selectedFile?.TryGetUri(out var uri) is not true) return; + if (selectedFile is null) return; try { var mkbAuth = Mkb79Auth.FromAccount(account); var jsonText = mkbAuth.ToJson(); - File.WriteAllText(uri.LocalPath, jsonText); + File.WriteAllText(selectedFile, jsonText); - await MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{uri.LocalPath}", "Success!"); + await MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{selectedFile}", "Success!"); } catch (Exception ex) { diff --git a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs index 2896c08b..5f825f44 100644 --- a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs @@ -63,11 +63,6 @@ public void GoToAudible_Tapped(object sender, Avalonia.Input.TappedEventArgs e) public void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e) => SaveAndClose(); - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - private class BookDetailsDialogViewModel : ViewModelBase { public class liberatedComboBoxItem diff --git a/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs index fc4a4012..1389e0d7 100644 --- a/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs @@ -172,23 +172,23 @@ private async Task saveRecords(IEnumerable records) } }); - var selectedFile = await StorageProvider.SaveFilePickerAsync(saveFileDialog); + var selectedFile = (await StorageProvider.SaveFilePickerAsync(saveFileDialog))?.TryGetLocalPath(); - if (selectedFile?.TryGetUri(out var uri) is not true) return; + if (selectedFile is null) return; - var ext = System.IO.Path.GetExtension(uri.LocalPath).ToLowerInvariant(); + var ext = System.IO.Path.GetExtension(selectedFile).ToLowerInvariant(); switch (ext) { case ".xlsx": default: - await Task.Run(() => RecordExporter.ToXlsx(uri.LocalPath, records)); + await Task.Run(() => RecordExporter.ToXlsx(selectedFile, records)); break; case ".csv": - await Task.Run(() => RecordExporter.ToCsv(uri.LocalPath, records)); + await Task.Run(() => RecordExporter.ToCsv(selectedFile, records)); break; case ".json": - await Task.Run(() => RecordExporter.ToJson(uri.LocalPath, libraryBook, records)); + await Task.Run(() => RecordExporter.ToJson(selectedFile, libraryBook, records)); break; } } diff --git a/Source/LibationAvalonia/Dialogs/DescriptionDisplayDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/DescriptionDisplayDialog.axaml.cs index a891b98f..1a934173 100644 --- a/Source/LibationAvalonia/Dialogs/DescriptionDisplayDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/DescriptionDisplayDialog.axaml.cs @@ -52,11 +52,5 @@ private void DescriptionTextBox_LostFocus(object sender, Avalonia.Interactivity. { Close(); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - } } diff --git a/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml.cs b/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml.cs index 9c48d6f4..91b8f039 100644 --- a/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/EditReplacementChars.axaml.cs @@ -170,10 +170,5 @@ public string CharacterToReplace public char Character => string.IsNullOrEmpty(_characterToReplace) ? default : _characterToReplace[0]; public bool IsDefault { get; private set; } } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } } diff --git a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml index ee973cdd..4723e4d0 100644 --- a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml @@ -23,7 +23,6 @@ Grid.Column="0" Grid.Row="1" Name="userEditTbox" - FontFamily="{Binding FontFamily}" Text="{Binding UserTemplateText, Mode=TwoWay}" /> + + diff --git a/Source/LibationAvalonia/Views/LiberateStatusButton.axaml.cs b/Source/LibationAvalonia/Views/LiberateStatusButton.axaml.cs new file mode 100644 index 00000000..edb1b24a --- /dev/null +++ b/Source/LibationAvalonia/Views/LiberateStatusButton.axaml.cs @@ -0,0 +1,80 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using DataLayer; +using LibationAvalonia.ViewModels; +using System; + +namespace LibationAvalonia.Views +{ + public partial class LiberateStatusButton : UserControl + { + public event EventHandler Click; + + public static readonly StyledProperty BookStatusProperty = + AvaloniaProperty.Register(nameof(BookStatus)); + + public static readonly StyledProperty PdfStatusProperty = + AvaloniaProperty.Register(nameof(PdfStatus)); + + public static readonly StyledProperty IsUnavailableProperty = + AvaloniaProperty.Register(nameof(IsUnavailable)); + + public static readonly StyledProperty ExpandedProperty = + AvaloniaProperty.Register(nameof(Expanded)); + + public static readonly StyledProperty IsSeriesProperty = + AvaloniaProperty.Register(nameof(IsSeries)); + + public LiberatedStatus BookStatus { get => GetValue(BookStatusProperty); set => SetValue(BookStatusProperty, value); } + public LiberatedStatus? PdfStatus { get => GetValue(PdfStatusProperty); set => SetValue(PdfStatusProperty, value); } + public bool IsUnavailable { get => GetValue(IsUnavailableProperty); set => SetValue(IsUnavailableProperty, value); } + public bool Expanded { get => GetValue(ExpandedProperty); set => SetValue(ExpandedProperty, value); } + public bool IsSeries { get => GetValue(IsSeriesProperty); set => SetValue(IsSeriesProperty, value); } + + private readonly LiberateStatusButtonViewModel viewModel = new(); + + public LiberateStatusButton() + { + InitializeComponent(); + button.DataContext = viewModel; + + if (Design.IsDesignMode) + { + BookStatus = LiberatedStatus.PartialDownload; + PdfStatus = null; + IsSeries = true; + } + } + + private void Button_Click(object sender, RoutedEventArgs e) => Click?.Invoke(this, EventArgs.Empty); + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (change.Property == BookStatusProperty) + { + viewModel.IsError = BookStatus is LiberatedStatus.Error; + viewModel.RedVisible = BookStatus is LiberatedStatus.NotLiberated; + viewModel.YellowVisible = BookStatus is LiberatedStatus.PartialDownload; + viewModel.GreenVisible = BookStatus is LiberatedStatus.Liberated; + } + else if (change.Property == PdfStatusProperty) + { + viewModel.PdfDownloadedVisible = PdfStatus is LiberatedStatus.Liberated; + viewModel.PdfNotDownloadedVisible = PdfStatus is LiberatedStatus.NotLiberated; + } + else if (change.Property == IsSeriesProperty) + { + viewModel.IsSeries = IsSeries; + } + else if (change.Property == ExpandedProperty) + { + viewModel.Expanded = Expanded; + } + + viewModel.IsButtonEnabled = !viewModel.IsError && (!IsUnavailable || (BookStatus is LiberatedStatus.Liberated && PdfStatus is null or LiberatedStatus.Liberated)); + + base.OnPropertyChanged(change); + } + } +} diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml index d2feee33..d4485a6a 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow.axaml @@ -138,7 +138,7 @@ - + @@ -172,12 +172,13 @@ - @@ -198,8 +199,8 @@ LiberateSeriesClicked="ProductsDisplay_LiberateSeriesClicked" ConvertToMp3Clicked="ProductsDisplay_ConvertToMp3Clicked" /> - - + + diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 6344c083..6a436916 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -2,9 +2,6 @@ using System.Collections.Generic; using System.Linq; using ApplicationServices; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Markup.Xaml; using Avalonia.ReactiveUI; using DataLayer; using LibationAvalonia.ViewModels; diff --git a/Source/LibationAvalonia/Views/ProcessBookControl.axaml b/Source/LibationAvalonia/Views/ProcessBookControl.axaml index e9bf30ac..0c2cb800 100644 --- a/Source/LibationAvalonia/Views/ProcessBookControl.axaml +++ b/Source/LibationAvalonia/Views/ProcessBookControl.axaml @@ -2,16 +2,16 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="90" MaxHeight="90" MinHeight="90" MinWidth="300" + mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="87" MaxHeight="87" MinHeight="87" MinWidth="300" x:Class="LibationAvalonia.Views.ProcessBookControl" Background="{Binding BackgroundColor}"> - + - + - + @@ -28,23 +28,35 @@ + + + + - - - - - - diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml index 79962d8f..87b50442 100644 --- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml +++ b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml @@ -38,7 +38,7 @@ Process Queue - + Queue Log - + @@ -99,7 +99,7 @@ - + @@ -108,6 +108,7 @@ + + + + + + + - - - + + + - - - + + + diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml b/Source/LibationAvalonia/Views/ProductsDisplay.axaml index a81c7cfd..38a6b59d 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml @@ -32,6 +32,11 @@ + @@ -58,13 +63,14 @@ - - - - - + @@ -202,7 +208,9 @@ diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index eed0e4d9..8f29b4ca 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -354,9 +354,9 @@ private void ProductsGrid_ColumnDisplayIndexChanged(object sender, DataGridColum #region Button Click Handlers - public async void LiberateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + public async void LiberateButton_Click(object sender, EventArgs e) { - var button = args.Source as Button; + var button = sender as LiberateStatusButton; if (button.DataContext is ISeriesEntry sEntry) { @@ -364,8 +364,7 @@ public async void LiberateButton_Click(object sender, Avalonia.Interactivity.Rou //Expanding and collapsing reset the list, which will cause focus to shift //to the topright cell. Reset focus onto the clicked button's cell. - var parentControl = (sender as Button).Parent as Control; - parentControl?.Focus(); + button.Focus(); } else if (button.DataContext is ILibraryBookEntry lbEntry) { diff --git a/Source/LibationAvalonia/libation.ico b/Source/LibationAvalonia/libation.ico deleted file mode 100644 index d3e0044392a5ccbebff66f3775ee6c32511c0d58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102551 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 diff --git a/Source/LibationWinForms/Resources/minus.png b/Source/LibationWinForms/Resources/minus.png index c0c5d15c1fe4233753ed8f81a19678d96c363888..8aee13b4c49686949c1357388e567a6b1bc21ef6 100644 GIT binary patch delta 377 zcmdm>Gl$u%Gr-TCmrII^fq{Y7)59eQNC$v02OE&=6a2ImNO2Z;L>4nJhzo%*WBU9{ zK@-jN>KTqG$JeJKN045 zs6P9lLDA|}FaE^|FtBh4EMWK-8oISw#ZuK$eWkkXIZr>^#n*nz--!C{^Xc1@>o=CJ zx>w%I*6=U2{{GV3_gk+^%75yR-CMyU7%ZXS(7?nYpx{u?2sVd_g(HB0k%>j40Z4|d zKUj3}{374?wcWZ(yy9(nmfsKWHe7tQ@2J?sUEI#w=lu3L5cTF?)Sn|jHQn~`!>E@k&ccPPG+ z?qLXzl=!u_t=r>l^A27?pgXt}4w(LCVqp0HpPAu;okQ0W_Ye_aFfw?$`njxgN@xNA DHF1ba literal 5680 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 From 397a516dc1d22722068d83f02b45ddbd0248e695 Mon Sep 17 00:00:00 2001 From: MBucari Date: Sat, 25 Mar 2023 16:33:00 -0600 Subject: [PATCH 14/16] Fix (#548) --- Source/ApplicationServices/LibraryCommands.cs | 22 +++++++++++++------ .../Dialogs/Login/AvaloniaLoginChoiceEager.cs | 2 +- .../Login/LoginChoiceEagerDialog.axaml.cs | 12 ++++++++++ .../ViewModels/ProductsDisplayViewModel.cs | 4 ++++ .../Views/MainWindow.ScanAuto.cs | 4 ++++ .../Views/MainWindow.ScanManual.cs | 4 ++++ .../Dialogs/Login/LoginChoiceEagerDialog.cs | 6 +++++ .../Dialogs/Login/WinformLoginChoiceEager.cs | 2 +- Source/LibationWinForms/Form1.ScanAuto.cs | 6 ++++- Source/LibationWinForms/Form1.ScanManual.cs | 4 ++++ .../GridView/ProductsDisplay.cs | 4 ++++ 11 files changed, 60 insertions(+), 10 deletions(-) diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index f0c6e881..b18e05c9 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -229,7 +229,7 @@ private static async Task> scanAccountsAsync(Func>>(); - await using LogArchiver archiver + await using LogArchiver archiver = Log.Logger.IsDebugEnabled() ? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip")) : default; @@ -238,16 +238,24 @@ private static async Task> scanAccountsAsync(Func a).ToList(); + var arrayOfLists = await Task.WhenAll(tasks); + var importItems = arrayOfLists.SelectMany(a => a).ToList(); return importItems; } diff --git a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs index a812c18c..1e0373b1 100644 --- a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs +++ b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs @@ -25,7 +25,7 @@ public async Task StartAsync(ChoiceIn choiceIn) { var dialog = new LoginChoiceEagerDialog(_account); - if (await dialog.ShowDialogAsync() is not DialogResult.OK) + if (await dialog.ShowDialogAsync() is not DialogResult.OK || string.IsNullOrWhiteSpace(dialog.Password)) return null; switch (dialog.LoginMethod) diff --git a/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml.cs index c62c53de..a2d4311d 100644 --- a/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/Login/LoginChoiceEagerDialog.axaml.cs @@ -4,6 +4,7 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; using System.Linq; +using System.Threading.Tasks; namespace LibationAvalonia.Dialogs.Login { @@ -31,6 +32,17 @@ public LoginChoiceEagerDialog(Account account):this() DataContext = this; } + protected override async Task SaveAndCloseAsync() + { + if (string.IsNullOrWhiteSpace(Password)) + { + await MessageBox.Show(this, "Please enter your password"); + return; + } + + await base.SaveAndCloseAsync(); + } + public async void ExternalLoginLink_Tapped(object sender, Avalonia.Input.TappedEventArgs e) { LoginMethod = LoginMethod.External; diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index a6c4bac6..e35cc9c8 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -427,6 +427,10 @@ public async Task ScanAndRemoveBooksAsync(params Account[] accounts) foreach (var r in removable) r.Remove = true; } + catch (OperationCanceledException) + { + Serilog.Log.Information("Audible login attempt cancelled by user"); + } catch (Exception ex) { await MessageBox.ShowAdminAlert( diff --git a/Source/LibationAvalonia/Views/MainWindow.ScanAuto.cs b/Source/LibationAvalonia/Views/MainWindow.ScanAuto.cs index f201e57f..7b9b33be 100644 --- a/Source/LibationAvalonia/Views/MainWindow.ScanAuto.cs +++ b/Source/LibationAvalonia/Views/MainWindow.ScanAuto.cs @@ -32,6 +32,10 @@ private void Configure_ScanAuto() { await LibraryCommands.ImportAccountAsync(Dialogs.Login.AvaloniaLoginChoiceEager.ApiExtendedFunc, accounts); } + catch (OperationCanceledException) + { + Serilog.Log.Information("Audible login attempt cancelled by user"); + } catch (Exception ex) { Serilog.Log.Logger.Error(ex, "Error invoking auto-scan"); diff --git a/Source/LibationAvalonia/Views/MainWindow.ScanManual.cs b/Source/LibationAvalonia/Views/MainWindow.ScanManual.cs index a94984c4..1349d02f 100644 --- a/Source/LibationAvalonia/Views/MainWindow.ScanManual.cs +++ b/Source/LibationAvalonia/Views/MainWindow.ScanManual.cs @@ -69,6 +69,10 @@ private async Task scanLibrariesAsync(params Account[] accounts) if (Configuration.Instance.ShowImportedStats && newAdded > 0) await MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}"); } + catch(OperationCanceledException) + { + Serilog.Log.Information("Audible login attempt cancelled by user"); + } catch (Exception ex) { await MessageBox.ShowAdminAlert( diff --git a/Source/LibationWinForms/Dialogs/Login/LoginChoiceEagerDialog.cs b/Source/LibationWinForms/Dialogs/Login/LoginChoiceEagerDialog.cs index e3c75906..b10055c2 100644 --- a/Source/LibationWinForms/Dialogs/Login/LoginChoiceEagerDialog.cs +++ b/Source/LibationWinForms/Dialogs/Login/LoginChoiceEagerDialog.cs @@ -37,6 +37,12 @@ private void submitBtn_Click(object sender, EventArgs e) Email = accountId; Password = this.passwordTb.Text; + if (string.IsNullOrWhiteSpace(Password)) + { + MessageBox.Show("Please enter your password"); + return; + } + Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new { email = Email?.ToMask(), passwordLength = Password.Length }); LoginMethod = AudibleApi.LoginMethod.Api; diff --git a/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs b/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs index 0543d80f..df313002 100644 --- a/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs +++ b/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs @@ -25,7 +25,7 @@ public Task StartAsync(ChoiceIn choiceIn) { using var dialog = new LoginChoiceEagerDialog(_account); - if (!ShowDialog(dialog)) + if (!ShowDialog(dialog) || string.IsNullOrWhiteSpace(dialog.Password)) return null; switch (dialog.LoginMethod) diff --git a/Source/LibationWinForms/Form1.ScanAuto.cs b/Source/LibationWinForms/Form1.ScanAuto.cs index 079140e9..ca91c770 100644 --- a/Source/LibationWinForms/Form1.ScanAuto.cs +++ b/Source/LibationWinForms/Form1.ScanAuto.cs @@ -38,7 +38,11 @@ private void Configure_ScanAuto() else await importAsync(); } - catch (Exception ex) + catch (OperationCanceledException) + { + Serilog.Log.Information("Audible login attempt cancelled by user"); + } + catch (Exception ex) { Serilog.Log.Logger.Error(ex, "Error invoking auto-scan"); } diff --git a/Source/LibationWinForms/Form1.ScanManual.cs b/Source/LibationWinForms/Form1.ScanManual.cs index b238cbd6..d745c6d2 100644 --- a/Source/LibationWinForms/Form1.ScanManual.cs +++ b/Source/LibationWinForms/Form1.ScanManual.cs @@ -80,6 +80,10 @@ private async Task scanLibrariesAsync(params Account[] accounts) if (Configuration.Instance.ShowImportedStats && newAdded > 0) MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}"); } + catch (OperationCanceledException) + { + Serilog.Log.Information("Audible login attempt cancelled by user"); + } catch (Exception ex) { MessageBoxLib.ShowAdminAlert( diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 2ad49a05..6196b6a6 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -288,6 +288,10 @@ public async Task ScanAndRemoveBooksAsync(params Account[] accounts) productsGrid_RemovableCountChanged(this, null); } + catch (OperationCanceledException) + { + Serilog.Log.Information("Audible login attempt cancelled by user"); + } catch (Exception ex) { MessageBoxLib.ShowAdminAlert( From c07bc884930550653b42071d32ba8886c4acc0f1 Mon Sep 17 00:00:00 2001 From: MBucari Date: Sat, 25 Mar 2023 21:18:23 -0600 Subject: [PATCH 15/16] Update AudibleApi --- Source/AudibleUtilities/AudibleUtilities.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index 06d586e3..27a8a4b5 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -5,7 +5,7 @@ - + From b876d909641b18103b11c99879e9fc6411dbc725 Mon Sep 17 00:00:00 2001 From: MBucari Date: Sun, 26 Mar 2023 08:43:33 -0600 Subject: [PATCH 16/16] Remove AudibleApi from solution --- Source/Libation.sln | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Source/Libation.sln b/Source/Libation.sln index f167db23..e7eda5c3 100644 --- a/Source/Libation.sln +++ b/Source/Libation.sln @@ -101,10 +101,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation UI", "Libation UI" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation CLI", "Libation CLI", "{47E27674-595D-4F7A-8CFB-127E768E1D1E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi", "..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj", "{DF6FBE88-A9A0-4CED-86B4-F35A130F349D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi.Common", "..\..\audible api\AudibleApi\AudibleApi.Common\AudibleApi.Common.csproj", "{093E79B6-9A57-46FE-8406-DB16BADAD427}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -223,14 +219,6 @@ Global {E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.Build.0 = Release|Any CPU - {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DF6FBE88-A9A0-4CED-86B4-F35A130F349D}.Release|Any CPU.Build.0 = Release|Any CPU - {093E79B6-9A57-46FE-8406-DB16BADAD427}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {093E79B6-9A57-46FE-8406-DB16BADAD427}.Debug|Any CPU.Build.0 = Debug|Any CPU - {093E79B6-9A57-46FE-8406-DB16BADAD427}.Release|Any CPU.ActiveCfg = Release|Any CPU - {093E79B6-9A57-46FE-8406-DB16BADAD427}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE