diff --git a/Images/Plus Minus.psd b/Images/Plus Minus.psd new file mode 100644 index 00000000..ae3fff21 Binary files /dev/null and b/Images/Plus Minus.psd differ diff --git a/Source/LibationWinForms/Resources/Stoplight1 with pdf.psd b/Images/Stoplight with pdf.psd similarity index 100% rename from Source/LibationWinForms/Resources/Stoplight1 with pdf.psd rename to Images/Stoplight with pdf.psd diff --git a/Source/LibationWinForms/Resources/Stoplight1.psd b/Images/Stoplight.psd similarity index 100% rename from Source/LibationWinForms/Resources/Stoplight1.psd rename to Images/Stoplight.psd diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 1c101306..b18e05c9 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; @@ -171,11 +172,64 @@ 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>>(); - await using LogArchiver archiver + await using LogArchiver archiver = Log.Logger.IsDebugEnabled() ? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip")) : default; @@ -184,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; } @@ -208,26 +270,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); - if (archiver is not null) + logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}"); + + await logDtoItemsAsync(dtoItems); + + 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 76c17f15..97caa4aa 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(); @@ -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 @@ -232,8 +218,11 @@ 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) { + ArgumentValidator.EnsureNotNull(parent, nameof(parent)); + ArgumentValidator.EnsureNotNull(children, nameof(children)); + //A series parent will always have exactly 1 Series parent.Series = new[] { @@ -246,7 +235,15 @@ private 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) { @@ -267,4 +264,4 @@ private static void setSeries(Item parent, IEnumerable children) } #endregion } -} \ No newline at end of file +} 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; + } + } +} 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/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/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; 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..da3b6fad 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -2,16 +2,71 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:LibationAvalonia" x:Class="LibationAvalonia.App"> - + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index b1d0fed2..13fa7356 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -12,6 +12,7 @@ using System.IO; using ApplicationServices; using Avalonia.Controls; +using Avalonia.Styling; namespace LibationAvalonia { @@ -23,6 +24,7 @@ public class App : Application public static IBrush ProcessQueueBookCancelledBrush { get; private set; } public static IBrush ProcessQueueBookDefaultBrush { get; private set; } public static IBrush SeriesEntryGridBackgroundBrush { get; private set; } + public static IBrush HyperlinkVisited { get; private set; } public static IAssetLoader AssetLoader { get; private set; } @@ -58,7 +60,7 @@ public override void OnFrameworkInitializationCompleted() if (config.LibationSettingsAreValid) { - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); ShowMainWindow(desktop); } else @@ -214,6 +216,10 @@ static async Task CancelInstallation() private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop) { + Current.RequestedThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) is "Dark" ? ThemeVariant.Dark : ThemeVariant.Light; + + //Reload colors for current theme + LoadStyles(); var mainWindow = new MainWindow(); desktop.MainWindow = MainWindow = mainWindow; mainWindow.RestoreSizeAndLocation(Configuration.Instance); @@ -227,8 +233,9 @@ private static void LoadStyles() ProcessQueueBookFailedBrush = AvaloniaUtils.GetBrushFromResources("ProcessQueueBookFailedBrush"); ProcessQueueBookCompletedBrush = AvaloniaUtils.GetBrushFromResources("ProcessQueueBookCompletedBrush"); ProcessQueueBookCancelledBrush = AvaloniaUtils.GetBrushFromResources("ProcessQueueBookCancelledBrush"); - ProcessQueueBookDefaultBrush = AvaloniaUtils.GetBrushFromResources("ProcessQueueBookDefaultBrush"); SeriesEntryGridBackgroundBrush = AvaloniaUtils.GetBrushFromResources("SeriesEntryGridBackgroundBrush"); + ProcessQueueBookDefaultBrush = AvaloniaUtils.GetBrushFromResources("ProcessQueueBookDefaultBrush"); + HyperlinkVisited = AvaloniaUtils.GetBrushFromResources(nameof(HyperlinkVisited)); } } } diff --git a/Source/LibationAvalonia/Assets/Arrows_left.png b/Source/LibationAvalonia/Assets/Arrows_left.png deleted file mode 100644 index a1a73311..00000000 Binary files a/Source/LibationAvalonia/Assets/Arrows_left.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/Arrows_right.png b/Source/LibationAvalonia/Assets/Arrows_right.png deleted file mode 100644 index 126dfa40..00000000 Binary files a/Source/LibationAvalonia/Assets/Arrows_right.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/DataGridFluentTheme.xaml b/Source/LibationAvalonia/Assets/DataGridFluentTheme.xaml new file mode 100644 index 00000000..82ef3f10 --- /dev/null +++ b/Source/LibationAvalonia/Assets/DataGridFluentTheme.xaml @@ -0,0 +1,588 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.6 + 0.8 + + M1875 1011l-787 787v-1798h-128v1798l-787 -787l-90 90l941 941l941 -941z + M1965 947l-941 -941l-941 941l90 90l787 -787v1798h128v-1798l787 787z + M515 93l930 931l-930 931l90 90l1022 -1021l-1022 -1021z + M109 486 19 576 1024 1581 2029 576 1939 486 1024 1401z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/Assets/LibationStyles.xaml b/Source/LibationAvalonia/Assets/LibationStyles.xaml deleted file mode 100644 index e3569ba2..00000000 --- a/Source/LibationAvalonia/Assets/LibationStyles.xaml +++ /dev/null @@ -1,18 +0,0 @@ - - - #cdffcd - - - - - - - - - - - - \ No newline at end of file diff --git a/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml b/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml new file mode 100644 index 00000000..d2e2d278 --- /dev/null +++ b/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml @@ -0,0 +1,69 @@ + + + + M30,0 H60 L30,50 L60,100 H30 L0,50 M85,0 H115 L85,50 L115,100 H85 L55,50 M140,0 H170 L140,50 L170,100 H140 L110,50 + M0,0 H100 V12 H53 L100,46 H0 L47,12 H0 + M0,36.66 L50,0 L100,36.66 + M0,0 L100,0 L50,36.66 + M0,0 H100 L53,34 H100 V46 H0 V34 H47 + M30,0 H50 V30 H80 V50 H50 V80 H30 V50 H0 V30 H30 + M31,0 H49 V100 H31 M58,0 H76 V100 H58 M85,0 H103 V100 H85 M8,85 V122 H129 V85 H117 V109 H20 V85 H8 M0,36 V66 L24,51 M114,36 V66 L138,51 + M0,0 H100 V100 H0 V0 M2,50 L36,82 L 93,27 L81,15 L36,59 L14,38 + M0,0 H100 V100 H0 V0 M15,71 L29,85 L50,64 L71,85 L85,71 L64,50 L85,29 L71,15 L50,36 L29,15 L15,29 L36,50 + M32,0 a 32,32 0 0 1 0,64 a 32,32 0 0 1 0,-64 m 0,4 a 28,28 0 0 1 0,56 a 28,28 0 0 1 0,-56 m-21,24 h42 a 1,1 0 0 1 1,1 v6 a 1,1 0 0 1 -1,1 h-42 a 1,1 0 0 1 -1,-1 v-6 a 1,1 0 0 1 1,-1 + + + + + M39,35 L50,24 H11 + A 11,11 0 0 0 0,35 V89 A 11,11 0 0 0 11,100 H64 A 11,11 0 0 0 75,89 V52 L64,63 V89 H11 V35 + M 51,65 H36 V50 + M 90.5,26.5 L55,62 L 39,45 L74,10 + M 78,6 L81.5,2.5 A 8,8 0 0 1 91.5,2 L98.5,9 A 8,8 0 0 1 97.5,19.5 L94,23 + + + + M0,2 A 2,2 0 0 1 2,0 H62 A2,2 0 0 1 64,2 V62 A 2,2 0 0 1 62,64 H 2 A 2,2 0 0 1 0,62 V2 + M 2,2 H62 V62 H2 V2 + M11,28 h42 a 1,1 0 0 1 1,1 v6 a 1,1 0 0 1 -1,1 h-42 a 1,1 0 0 1 -1,-1 v-6 a 1,1 0 0 1 1,-1 + + M28,53 v-42 a 1,1 0 0 1 1,-1 h6 a 1,1 0 0 1 1,1 v42 a 1,1 0 0 1 -1,1 h-6 a 1,1 0 0 1 -1,-1 + + + + + + M0,12 A 12,12 0 0 1 12,0 H34 A 12,12 0 0 1 46,12 V88 A 12,12 0 0 1 34,100 H12 A 12,12 0 0 1 0,88 V12 + M20,8 H26 A 12,12 0 0 1 26,32 H20 A 12,12 0 0 1 20,8 + M20,38 H26 A 12,12 0 0 1 26,62 H20 A 12,12 0 0 1 20,38 + M20,68 H26 A 12,12 0 0 1 26,92 H20 A 12,12 0 0 1 20,68 + + + + M4,38.5 H3 A 3,3 0 0 1 0,35.5 V21.4 A 3,3 0 0 1 3,18.4 H4 V2 A 2,2 0 0 1 6,0 H30.5 L41,12 V18.4 A 3,3 0 0 1 45,21.4 V35.5 A 3,3 0 0 1 42,38.5 H41 V48.5 A 2,2 0 0 1 39,50.5 H6 A 2,2 0 0 1 4,48.5 + M6,38.5 H39 V48.5 H6 V38.5 + M6,18.4 V2 H29 V12 A 1,1 0 0 0 30,13 H39 V18.4 + M 4.3179,36 c 0,0 0.122,-14.969 0.122,-14.969 1.469,-0.194 2.939,-0.388 4.5,-0.362 1.561,0.026 3.214,0.27 4.357,0.944 1.143,0.674 1.775,1.776 2.015,2.959 0.24,1.184 0.087,2.449 -0.5,3.52 -0.587,1.071 -1.607,1.949 -2.816,2.352 -1.209,0.403 -2.607,0.332 -4.005,0.26 0,0 -0.031,5.265 -0.031,5.265 0,0 -3.673,0.122 -3.673,0.122 0,0 0.031,-0.092 0.031,-0.092 + m 3.643,-12.428 c 0,0 0.031,4.286 0.031,4.286 0.735,0.051 1.47,0.102 2.107,-0.056 0.638,-0.158 1.178,-0.526 1.459,-1.122 0.281,-0.597 0.301,-1.423 0.01,-2.005 -0.291,-0.582 -0.893,-0.918 -1.546,-1.061 -0.653,-0.143 -1.357,-0.092 -1.709,-0.066 -0.352,0.026 -0.352,0.026 -0.352,0.026 + m 9.428,12.428 c 2.265,0.245 4.531,0.49 6.674,0.066 2.143,-0.424 4.163,-1.515 5.285,-3.081 1.122,-1.566 1.347,-3.607 1.27,-5.306 -0.076,-1.699 -0.454,-3.056 -1.454,-4.219 -1,-1.163 -2.622,-2.133 -4.704,-2.505 -2.082,-0.373 -4.623,-0.148 -7.164,0.076 0,0 0.092,14.969 0.092,14.969 + m 3.49,-12.398 c 0,0 0,9.673 0,9.673 0.888,0.02 1.776,0.041 2.653,-0.179 0.877,-0.219 1.745,-0.679 2.367,-1.541 0.622,-0.862 1,-2.127 0.98,-3.403 -0.02,-1.275 -0.439,-2.561 -1.193,-3.337 -0.755,-0.776 -1.847,-1.041 -2.704,-1.158 -0.857,-0.117 -1.48,-0.087 -2.102,-0.056 + m 11.908,12.245 v-14.785 h8.969 v2.51 h-5.786 v3.612 h5.388 v2.51 h-5.449 v6.092 + + + + M29,44 V58.7498 H35.0491 A 1.5,1.5 0 0 1 36.1342,61.2861 L23.5607,73.8595 A 1.5,1.5 0 0 1 21.4393,73.8595 L8.8658,61.2861 A 1.5,1.5 0 0 1 9.9509,58.7498 H16 V44 A 1.5,1.5 0 0 1 17.5,42.5 H27.5 A 1.5,1.5 0 0 1 29,44 + + + + + + M5.65,4.3 h-2.75 a2.9,2.25 0 0 0 -2.9,2.25 v7.2 + a2.9,2.25 0 0 0 2.9,2.25 h10.2 a2.9,2.25 0 0 0 2.9,-2.25 v-7.2 a2.9,2.25 0 0 0 -2.9,-2.25 + h-2.75 v1.6 h2.75 a1.3,0.65 0 0 1 1.3,0.65 v7.2 a1.3,0.65 0 0 1 -1.3,0.65 h-10.2 a1.3,0.65 0 0 1 -1.3,-0.65 v-7.2 a1.3,0.65 0 0 1 1.3,-0.65 h2.75 v-1.6 + M7.2,0.8 a 0.8,0.8 0 0 1 1.6,0 v8 l0.9929,-0.9929 a 0.8,0.8 0 0 1 1.1314,1.1314 l-2.3586,2.3586 + a 0.8,0.8 0 0 1 -1.1314,0 l-2.3586,-2.3586 a 0.8,0.8 0 0 1 1.1314,-1.1314 l0.9929,0.9929 v8 + + + + diff --git a/Source/LibationAvalonia/Assets/cancel.png b/Source/LibationAvalonia/Assets/cancel.png deleted file mode 100644 index fa34f935..00000000 Binary files a/Source/LibationAvalonia/Assets/cancel.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/completed.png b/Source/LibationAvalonia/Assets/completed.png deleted file mode 100644 index 3cd61981..00000000 Binary files a/Source/LibationAvalonia/Assets/completed.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/down.png b/Source/LibationAvalonia/Assets/down.png deleted file mode 100644 index 2536c961..00000000 Binary files a/Source/LibationAvalonia/Assets/down.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/download-arrow.png b/Source/LibationAvalonia/Assets/download-arrow.png deleted file mode 100644 index 16617998..00000000 Binary files a/Source/LibationAvalonia/Assets/download-arrow.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/edit-tags-25x25.png b/Source/LibationAvalonia/Assets/edit-tags-25x25.png deleted file mode 100644 index 82b24209..00000000 Binary files a/Source/LibationAvalonia/Assets/edit-tags-25x25.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/edit-tags-50x50.png b/Source/LibationAvalonia/Assets/edit-tags-50x50.png deleted file mode 100644 index 7b0043ac..00000000 Binary files a/Source/LibationAvalonia/Assets/edit-tags-50x50.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/edit_25x25.png b/Source/LibationAvalonia/Assets/edit_25x25.png deleted file mode 100644 index 12e70d0f..00000000 Binary files a/Source/LibationAvalonia/Assets/edit_25x25.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/edit_64x64.png b/Source/LibationAvalonia/Assets/edit_64x64.png deleted file mode 100644 index 1d9e5f83..00000000 Binary files a/Source/LibationAvalonia/Assets/edit_64x64.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/error.png b/Source/LibationAvalonia/Assets/error.png deleted file mode 100644 index 700ce41e..00000000 Binary files a/Source/LibationAvalonia/Assets/error.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/errored.png b/Source/LibationAvalonia/Assets/errored.png deleted file mode 100644 index bb8ba7ef..00000000 Binary files a/Source/LibationAvalonia/Assets/errored.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/first.png b/Source/LibationAvalonia/Assets/first.png deleted file mode 100644 index e470c697..00000000 Binary files a/Source/LibationAvalonia/Assets/first.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/glass-with-glow_16.png b/Source/LibationAvalonia/Assets/glass-with-glow_16.png deleted file mode 100644 index 05e40bec..00000000 Binary files a/Source/LibationAvalonia/Assets/glass-with-glow_16.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/import_16x16.png b/Source/LibationAvalonia/Assets/import_16x16.png deleted file mode 100644 index 40b582b1..00000000 Binary files a/Source/LibationAvalonia/Assets/import_16x16.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/last.png b/Source/LibationAvalonia/Assets/last.png deleted file mode 100644 index 3c3ea886..00000000 Binary files a/Source/LibationAvalonia/Assets/last.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/liberate_green.png b/Source/LibationAvalonia/Assets/liberate_green.png deleted file mode 100644 index 86171e0c..00000000 Binary files a/Source/LibationAvalonia/Assets/liberate_green.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/liberate_green_pdf_no.png b/Source/LibationAvalonia/Assets/liberate_green_pdf_no.png deleted file mode 100644 index a128c088..00000000 Binary files a/Source/LibationAvalonia/Assets/liberate_green_pdf_no.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/liberate_green_pdf_yes.png b/Source/LibationAvalonia/Assets/liberate_green_pdf_yes.png deleted file mode 100644 index baac0151..00000000 Binary files a/Source/LibationAvalonia/Assets/liberate_green_pdf_yes.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/liberate_red.png b/Source/LibationAvalonia/Assets/liberate_red.png deleted file mode 100644 index 8e4b34e4..00000000 Binary files a/Source/LibationAvalonia/Assets/liberate_red.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/liberate_red_pdf_no.png b/Source/LibationAvalonia/Assets/liberate_red_pdf_no.png deleted file mode 100644 index 6506603c..00000000 Binary files a/Source/LibationAvalonia/Assets/liberate_red_pdf_no.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/liberate_red_pdf_yes.png b/Source/LibationAvalonia/Assets/liberate_red_pdf_yes.png deleted file mode 100644 index 0d5b5eb6..00000000 Binary files a/Source/LibationAvalonia/Assets/liberate_red_pdf_yes.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/liberate_yellow.png b/Source/LibationAvalonia/Assets/liberate_yellow.png deleted file mode 100644 index 8b3e8aab..00000000 Binary files a/Source/LibationAvalonia/Assets/liberate_yellow.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/liberate_yellow_pdf_no.png b/Source/LibationAvalonia/Assets/liberate_yellow_pdf_no.png deleted file mode 100644 index 2bddcffd..00000000 Binary files a/Source/LibationAvalonia/Assets/liberate_yellow_pdf_no.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/liberate_yellow_pdf_yes.png b/Source/LibationAvalonia/Assets/liberate_yellow_pdf_yes.png deleted file mode 100644 index b51a28ad..00000000 Binary files a/Source/LibationAvalonia/Assets/liberate_yellow_pdf_yes.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/minus.png b/Source/LibationAvalonia/Assets/minus.png deleted file mode 100644 index c0c5d15c..00000000 Binary files a/Source/LibationAvalonia/Assets/minus.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/plus.png b/Source/LibationAvalonia/Assets/plus.png deleted file mode 100644 index 1cd1c630..00000000 Binary files a/Source/LibationAvalonia/Assets/plus.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/queued.png b/Source/LibationAvalonia/Assets/queued.png deleted file mode 100644 index f30221c3..00000000 Binary files a/Source/LibationAvalonia/Assets/queued.png and /dev/null differ diff --git a/Source/LibationAvalonia/Assets/up.png b/Source/LibationAvalonia/Assets/up.png deleted file mode 100644 index 7c00155a..00000000 Binary files a/Source/LibationAvalonia/Assets/up.png and /dev/null differ diff --git a/Source/LibationAvalonia/AvaloniaUtils.cs b/Source/LibationAvalonia/AvaloniaUtils.cs index 9a1090be..f92e1c4d 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; @@ -12,8 +13,8 @@ internal static class AvaloniaUtils 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) + { + if (App.Current.TryGetResource(name, App.Current.ActualThemeVariant, out var value) && value is IBrush brush) return brush; return defaultBrush; } @@ -21,7 +22,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 b/Source/LibationAvalonia/Controls/GroupBox.axaml index f8c2b755..91246b19 100644 --- a/Source/LibationAvalonia/Controls/GroupBox.axaml +++ b/Source/LibationAvalonia/Controls/GroupBox.axaml @@ -27,7 +27,7 @@ VerticalAlignment="Top"> 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 b/Source/LibationAvalonia/Controls/LinkLabel.axaml index e1043dfc..f15725b0 100644 --- a/Source/LibationAvalonia/Controls/LinkLabel.axaml +++ b/Source/LibationAvalonia/Controls/LinkLabel.axaml @@ -6,7 +6,7 @@ x:Class="LibationAvalonia.Controls.LinkLabel"> diff --git a/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs b/Source/LibationAvalonia/Controls/LinkLabel.axaml.cs index 30b0d74a..4d7ee7ac 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 = App.HyperlinkVisited; + } + protected override void OnPointerEntered(PointerEventArgs e) { this.Cursor = HandCursor; @@ -25,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 b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml index c49b9dbb..07647a43 100644 --- a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml @@ -33,9 +33,9 @@ + + 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.Export.cs b/Source/LibationAvalonia/Views/MainWindow.Export.cs index 3e8cdc87..0e849899 100644 --- a/Source/LibationAvalonia/Views/MainWindow.Export.cs +++ b/Source/LibationAvalonia/Views/MainWindow.Export.cs @@ -20,7 +20,7 @@ public async void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.I var options = new FilePickerSaveOptions { Title = "Where to export Library", - SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix), + SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix), SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}", DefaultExtension = "xlsx", ShowOverwritePrompt = true, @@ -46,26 +46,26 @@ public async void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.I } }; - 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; - var ext = FileUtility.GetStandardizedExtension(System.IO.Path.GetExtension(uri.LocalPath)); + var ext = FileUtility.GetStandardizedExtension(System.IO.Path.GetExtension(selectedFile)); switch (ext) { case ".xlsx": // xlsx default: - LibraryExporter.ToXlsx(uri.LocalPath); + LibraryExporter.ToXlsx(selectedFile); break; case ".csv": // csv - LibraryExporter.ToCsv(uri.LocalPath); + LibraryExporter.ToCsv(selectedFile); break; case ".json": // json - LibraryExporter.ToJson(uri.LocalPath); + LibraryExporter.ToJson(selectedFile); break; } - await MessageBox.Show("Library exported to:\r\n" + uri.LocalPath, "Library Exported"); + await MessageBox.Show("Library exported to:\r\n" + selectedFile, "Library Exported"); } catch (Exception ex) { 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/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.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/LibationAvalonia/Views/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml index f6490cd1..d4485a6a 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow.axaml @@ -138,7 +138,7 @@ - + @@ -172,12 +172,13 @@ - @@ -195,10 +196,11 @@ Name="productsDisplay" DataContext="{Binding ProductsDisplay}" LiberateClicked="ProductsDisplay_LiberateClicked" + LiberateSeriesClicked="ProductsDisplay_LiberateSeriesClicked" ConvertToMp3Clicked="ProductsDisplay_ConvertToMp3Clicked" /> - - + + diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 79fd7821..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; @@ -23,10 +20,6 @@ public MainWindow() this.DataContext = _viewModel = new MainWindowViewModel(); InitializeComponent(); -#if DEBUG - this.AttachDevTools(); -#endif - FindAllControls(); // eg: if one of these init'd productsGrid, then another can't reliably subscribe to it Configure_BackupCounts(); @@ -70,18 +63,7 @@ private async void MainWindow_LibraryLoaded(object sender, List dbB _viewModel.ProductsDisplay.BindToGrid(dbBooks); } - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - public void OnLoad() => Load?.Invoke(this, EventArgs.Empty); public void OnLibraryLoaded(List initialLibrary) => LibraryLoaded?.Invoke(this, initialLibrary); - - private void FindAllControls() - { - quickFiltersToolStripMenuItem = this.FindControl(nameof(quickFiltersToolStripMenuItem)); - productsDisplay = this.FindControl(nameof(productsDisplay)); - } } } 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/ProcessBookControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessBookControl.axaml.cs index 59367b52..ccbdbe43 100644 --- a/Source/LibationAvalonia/Views/ProcessBookControl.axaml.cs +++ b/Source/LibationAvalonia/Views/ProcessBookControl.axaml.cs @@ -41,10 +41,5 @@ public void MoveDown_Click(object sender, Avalonia.Interactivity.RoutedEventArgs => PositionButtonClicked?.Invoke(DataItem, QueuePosition.OneDown); public void MoveLast_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => PositionButtonClicked?.Invoke(DataItem, QueuePosition.Last); - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } } 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/ProcessQueueControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs index ed6dfef0..51f25bab 100644 --- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs +++ b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs @@ -94,11 +94,6 @@ public void NumericUpDown_KeyDown(object sender, Avalonia.Input.KeyEventArgs e) if (e.Key == Avalonia.Input.Key.Enter && sender is Avalonia.Input.IInputElement input) input.Focus(); } - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - #region Control event handlers private async void ProcessBookControl2_CancelButtonClicked(ProcessBookViewModel item) diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml b/Source/LibationAvalonia/Views/ProductsDisplay.axaml index 1af04f29..38a6b59d 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml @@ -30,6 +30,12 @@ + + + @@ -57,13 +63,14 @@ - - - - - + @@ -80,7 +87,7 @@ - + @@ -201,7 +208,9 @@ diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 1461e6d7..8f29b4ca 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; @@ -28,6 +29,7 @@ public partial class ProductsDisplay : UserControl public ProductsDisplay() { InitializeComponent(); + DataGridContextMenus.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded; if (Design.IsDesignMode) { @@ -73,14 +75,6 @@ private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChanged } } - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - - productsGrid = this.FindControl(nameof(productsGrid)); - DataGridContextMenus.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded; - } - #region Cell Context Menu public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args) @@ -90,74 +84,146 @@ 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 window = this.GetParentWindow(); + + var openFileDialogOptions = new FilePickerOpenOptions { + Title = $"Locate the audio file for '{entry.Book.Title}'", + AllowMultiple = false, + SuggestedStartLocation = await window.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix), + FileTypeFilter = new FilePickerFileType[] + { new("All files (*.*)") { Patterns = new[] { "*" } }, - } - }; + } + }; + + var selectedFiles = await window.StorageProvider.OpenFilePickerAsync(openFileDialogOptions); + var selectedFile = selectedFiles.SingleOrDefault()?.TryGetLocalPath(); - var selectedFiles = await this.GetParentWindow().StorageProvider.OpenFilePickerAsync(openFileDialogOptions); - var selectedFile = selectedFiles.SingleOrDefault(); + if (selectedFile is not null) + FilePathCache.Insert(entry.AudibleProductId, selectedFile); + } + catch (Exception ex) + { + var msg = "Error saving book's location"; + await MessageBox.ShowAdminAlert(null, msg, msg, ex); + } + }; - if (selectedFile?.TryGetUri(out var uri) is true) - FilePathCache.Insert(entry.AudibleProductId, uri.LocalPath); - } - catch (Exception 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 + }; + args.ContextMenuItems.Add(convertToMp3MenuItem); + + convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook); + + #endregion + } + + args.ContextMenuItems.Add(new Separator()); + + #region View Bookmarks/Clips + + if (!entry.Liberate.IsSeries) { - Header = "_Convert to Mp3", - IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated - }; - convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook); - var bookRecordMenuItem = new MenuItem { Header = "View _Bookmarks/Clips" }; - bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window); + 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 + #region View All Series - args.ContextMenuItems.AddRange(new Control[] + if (entry.Book.SeriesLink.Any()) { - setDownloadMenuItem, - setNotDownloadMenuItem, - removeMenuItem, - locateFileMenuItem, - convertToMp3MenuItem, - new Separator(), - bookRecordMenuItem - }); + 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 { @@ -288,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) { @@ -298,7 +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. - (sender as Button).Parent?.Focus(); + button.Focus(); } else if (button.DataContext is ILibraryBookEntry lbEntry) { 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..712eb24e --- /dev/null +++ b/Source/LibationAvalonia/Views/SeriesViewGrid.axaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/LibationAvalonia/libation.ico b/Source/LibationAvalonia/libation.ico deleted file mode 100644 index d3e00443..00000000 Binary files a/Source/LibationAvalonia/libation.ico and /dev/null differ diff --git a/Source/LibationFileManager/Configuration.cs b/Source/LibationFileManager/Configuration.cs index 3c71abe9..74d95c86 100644 --- a/Source/LibationFileManager/Configuration.cs +++ b/Source/LibationFileManager/Configuration.cs @@ -16,11 +16,27 @@ 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; + + 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 + { + Directory.CreateDirectory(booksDir); + + pDic.SetString(nameof(Books), booksDir); + + return booksDir is not null && Directory.Exists(booksDir); + } + catch { return false; } + } return true; } 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/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs index 0502b3f6..4aa8fdde 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,9 +99,12 @@ 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(); + 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. 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/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/SeriesItem.cs b/Source/LibationUiBase/SeriesView/SeriesItem.cs new file mode 100644 index 00000000..44b451e4 --- /dev/null +++ b/Source/LibationUiBase/SeriesView/SeriesItem.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/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.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/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/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/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/ProductsDisplay.Designer.cs b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs index 1e32af52..248a5afb 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs @@ -28,35 +28,34 @@ 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.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..6196b6a6 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; @@ -20,6 +21,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 +98,146 @@ 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 + #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 + } + + #endregion + #region Scan and Remove Books public void CloseRemoveBooksColumn() @@ -146,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( @@ -206,12 +352,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.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 c5d59792..54a82439 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -1,16 +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.Diagnostics; using System.Drawing; using System.Linq; -using System.Threading.Tasks; using System.Windows.Forms; namespace LibationWinForms.GridView @@ -18,23 +14,24 @@ 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 { /// 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; public new event EventHandler Scroll; public event EventHandler RemovableCountChanged; + public event ProductsGridCellContextMenuStripNeededEventHandler LiberateContextMenuStripNeeded; private GridEntryBindingList bindingList; internal IEnumerable GetVisibleBooks() => bindingList - .BookEntries() + .GetFilteredInItems() .Select(lbe => lbe.LibraryBook); internal IEnumerable GetAllBookEntries() => bindingList.AllItems().BookEntries(); @@ -44,9 +41,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); @@ -84,7 +115,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)); @@ -104,101 +135,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 @@ -248,7 +184,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 +253,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,13 +300,15 @@ 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); } //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); @@ -395,7 +333,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 diff --git a/Source/LibationWinForms/Resources/minus.png b/Source/LibationWinForms/Resources/minus.png index c0c5d15c..8aee13b4 100644 Binary files a/Source/LibationWinForms/Resources/minus.png and b/Source/LibationWinForms/Resources/minus.png differ diff --git a/Source/LibationWinForms/Resources/plus.png b/Source/LibationWinForms/Resources/plus.png index 1cd1c630..41b056db 100644 Binary files a/Source/LibationWinForms/Resources/plus.png and b/Source/LibationWinForms/Resources/plus.png differ 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