diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs index e0a6506f..e1159d67 100644 --- a/Source/AaxDecrypter/NetworkFileStream.cs +++ b/Source/AaxDecrypter/NetworkFileStream.cs @@ -221,9 +221,10 @@ namespace AaxDecrypter try { + int bytesRead; do { - var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ); + bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ); _writeFile.Write(buff, 0, bytesRead); downloadPosition += bytesRead; @@ -237,7 +238,7 @@ namespace AaxDecrypter downloadedPiece.Set(); } - } while (downloadPosition < ContentLength && !IsCancelled); + } while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0); _writeFile.Close(); _networkStream.Close(); diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index e16f52f0..c5d64f0e 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -5,10 +5,10 @@ using System.Linq; using System.Reflection; using ApplicationServices; using AudibleUtilities; -using Dinah.Core; using Dinah.Core.IO; using Dinah.Core.Logging; using LibationFileManager; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json.Linq; using Serilog; @@ -405,25 +405,71 @@ namespace AppScaffolding public static void migrate_from_7_10_1(Configuration config) { - //This migration removes books and series with SERIES_ prefix that were created - //as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2 + var lastNigrationThres = config.GetNonString($"{nameof(migrate_from_7_10_1)}_ThrewError"); - var migrated = config.GetNonString(nameof(migrate_from_7_10_1)); + if (lastNigrationThres) return; - if (migrated) return; + try + { - using var context = DbContexts.GetContext(); + //https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629 + //This migration helps fix databases contaminated with the 7.10.1 hack workaround + //and those with improperly identified or missing series. This does not solve cases + //where individual episodes are in the db with a valid series link, but said series' + //parents have not been imported into the database. For those cases, Libation will + //attempt fixup by retrieving parents from the catalog endpoint - var booksToRemove = context.Books.Where(b => b.AudibleProductId.StartsWith("SERIES_")).ToArray(); - var seriesToRemove = context.Series.Where(s => s.AudibleSeriesId.StartsWith("SERIES_")).ToArray(); - var lbToRemove = context.LibraryBooks.Where(lb => booksToRemove.Any(b => b == lb.Book)).ToArray(); + using var context = DbContexts.GetContext(); - context.LibraryBooks.RemoveRange(lbToRemove); - context.Books.RemoveRange(booksToRemove); - context.Series.RemoveRange(seriesToRemove); + //This migration removes books and series with SERIES_ prefix that were created + //as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2 + string removeHackSeries = "delete " + + "from series " + + "where AudibleSeriesId like 'SERIES%'"; - LibraryCommands.SaveContext(context); - config.SetObject(nameof(migrate_from_7_10_1), true); + string removeHackBooks = "delete " + + "from books " + + "where AudibleProductId like 'SERIES%'"; + + //Detect series parents that were added to the database as books with ContentType.Episode, + //and change them to ContentType.Parent + string updateContentType = + "UPDATE books " + + "SET contenttype = 4 " + + "WHERE audibleproductid IN (SELECT books.audibleproductid " + + "FROM books " + + "INNER JOIN series " + + "ON ( books.audibleproductid = " + + "series.audibleseriesid) " + + "WHERE books.contenttype = 2)"; + + //Then detect series parents that were added to the database as books with ContentType.Parent + //but are missing a series link, and add the link (don't know how this happened) + string addMissingSeriesLink = + "INSERT INTO seriesbook " + + "SELECT series.seriesid, " + + "books.bookid, " + + "'- 1' " + + "FROM books " + + "LEFT OUTER JOIN seriesbook " + + "ON books.bookid = seriesbook.bookid " + + "INNER JOIN series " + + "ON books.audibleproductid = series.audibleseriesid " + + "WHERE books.contenttype = 4 " + + "AND seriesbook.seriesid IS NULL"; + + context.Database.ExecuteSqlRaw(removeHackSeries); + context.Database.ExecuteSqlRaw(removeHackBooks); + context.Database.ExecuteSqlRaw(updateContentType); + context.Database.ExecuteSqlRaw(addMissingSeriesLink); + + LibraryCommands.SaveContext(context); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error occured while running database migrations in {0}", nameof(migrate_from_7_10_1)); + config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true); + } } } } diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 66ee71e1..c0056649 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -27,10 +27,17 @@ namespace ApplicationServices ScanEnd += (_, __) => Scanning = false; } - public static async Task> FindInactiveBooks(Func> apiExtendedfunc, List existingLibrary, params Account[] accounts) + public static async Task> FindInactiveBooks(Func> apiExtendedfunc, IEnumerable existingLibrary, params Account[] accounts) { logRestart(); + lock (_lock) + { + if (Scanning) + return new(); + } + ScanBegin?.Invoke(null, accounts.Length); + //These are the minimum response groups required for the //library scanner to pass all validation and filtering. var libraryOptions = new LibraryOptions @@ -83,6 +90,7 @@ namespace ApplicationServices { stop(); var putBreakPointHere = logOutput; + ScanEnd?.Invoke(null, null); } } @@ -100,8 +108,8 @@ namespace ApplicationServices { if (Scanning) return (0, 0); - ScanBegin?.Invoke(null, accounts.Length); } + ScanBegin?.Invoke(null, accounts.Length); logTime($"pre {nameof(scanAccountsAsync)} all"); var libraryOptions = new LibraryOptions @@ -118,6 +126,22 @@ namespace ApplicationServices if (totalCount == 0) return default; + + Log.Logger.Information("Begin scan for orphaned episode parents"); + var newParents = await findAndAddMissingParents(apiExtendedfunc, accounts); + Log.Logger.Information($"Orphan episode scan complete. New parents count {newParents}"); + + if (newParents >= 0) + { + //If any episodes are still orphaned, their series have been + //removed from the catalog and wel'll never be able to find them. + + //only do this if findAndAddMissingParents returned >= 0. If it + //returned < 0, an error happened and there's still a chance that + //a future successful run will find missing parents. + removedOrphanedEpisodes(); + } + Log.Logger.Information("Begin long-running import"); logTime($"pre {nameof(importIntoDbAsync)}"); var newCount = await importIntoDbAsync(importItems); @@ -199,7 +223,7 @@ namespace ApplicationServices using var context = DbContexts.GetContext(); var libraryBookImporter = new LibraryBookImporter(context); var newCount = await Task.Run(() => libraryBookImporter.Import(importItems)); - logTime("importIntoDbAsync -- post Import()"); + logTime("importIntoDbAsync -- post Import()"); int qtyChanges = SaveContext(context); logTime("importIntoDbAsync -- post SaveChanges"); @@ -211,7 +235,85 @@ namespace ApplicationServices return newCount; } - public static int SaveContext(LibationContext context) + static void removedOrphanedEpisodes() + { + using var context = DbContexts.GetContext(); + try + { + var orphanedEpisodes = + context + .GetLibrary_Flat_NoTracking(includeParents: true) + .FindOrphanedEpisodes(); + + context.LibraryBooks.RemoveRange(orphanedEpisodes); + context.Books.RemoveRange(orphanedEpisodes.Select(lb => lb.Book)); + + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error occured while trying to remove orphaned episodes from the database"); + } + } + + static async Task findAndAddMissingParents(Func> apiExtendedfunc, Account[] accounts) + { + using var context = DbContexts.GetContext(); + + var library = context.GetLibrary_Flat_NoTracking(includeParents: true); + + try + { + var orphanedEpisodes = library.FindOrphanedEpisodes().ToList(); + + if (!orphanedEpisodes.Any()) + return -1; + + var orphanedSeries = + orphanedEpisodes + .SelectMany(lb => lb.Book.SeriesLink) + .DistinctBy(s => s.Series.AudibleSeriesId) + .ToList(); + + // We're only calling the Catalog endpoint, so it doesn't matter which account we use. + var apiExtended = await apiExtendedfunc(accounts[0]); + + var seriesParents = orphanedSeries.Select(o => o.Series.AudibleSeriesId).ToList(); + var items = await apiExtended.Api.GetCatalogProductsAsync(seriesParents, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); + + List newParentsImportItems = new(); + foreach (var sp in orphanedSeries) + { + var seriesItem = items.First(i => i.Asin == sp.Series.AudibleSeriesId); + + if (seriesItem.Relationships is null) + continue; + + var episode = orphanedEpisodes.First(l => l.Book.AudibleProductId == sp.Book.AudibleProductId); + + seriesItem.PurchaseDate = new DateTimeOffset(episode.DateAdded); + seriesItem.Series = new AudibleApi.Common.Series[] + { + new AudibleApi.Common.Series{ Asin = seriesItem.Asin, Title = seriesItem.TitleWithSubtitle, Sequence = "-1"} + }; + + newParentsImportItems.Add(new ImportItem { DtoItem = seriesItem, AccountId = episode.Account, LocaleName = episode.Book.Locale }); + } + + var newCoutn = new LibraryBookImporter(context) + .Import(newParentsImportItems); + + await context.SaveChangesAsync(); + + return newCoutn; + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error occured while trying to scan for orphaned episode parents."); + return -1; + } + } + + public static int SaveContext(LibationContext context) { try { diff --git a/Source/AudibleUtilities/Mkb79Auth.cs b/Source/AudibleUtilities/Mkb79Auth.cs new file mode 100644 index 00000000..f3bc6db5 --- /dev/null +++ b/Source/AudibleUtilities/Mkb79Auth.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AudibleApi; +using AudibleApi.Authorization; +using Dinah.Core; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AudibleUtilities +{ + public partial class Mkb79Auth : IIdentityMaintainer + { + [JsonProperty("website_cookies")] + private JObject _websiteCookies { get; set; } + + [JsonProperty("adp_token")] + public string AdpToken { get; private set; } + + [JsonProperty("access_token")] + public string AccessToken { get; private set; } + + [JsonProperty("refresh_token")] + public string RefreshToken { get; private set; } + + [JsonProperty("device_private_key")] + public string DevicePrivateKey { get; private set; } + + [JsonProperty("store_authentication_cookie")] + private JObject _storeAuthenticationCookie { get; set; } + + [JsonProperty("device_info")] + public DeviceInfo DeviceInfo { get; private set; } + + [JsonProperty("customer_info")] + public CustomerInfo CustomerInfo { get; private set; } + + [JsonProperty("expires")] + private double _expires { get; set; } + + [JsonProperty("locale_code")] + public string LocaleCode { get; private set; } + + [JsonProperty("activation_bytes")] + public string ActivationBytes { get; private set; } + + [JsonIgnore] + public Dictionary WebsiteCookies + { + get => _websiteCookies.ToObject>(); + private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings)); + } + + [JsonIgnore] + public string StoreAuthenticationCookie + { + get => _storeAuthenticationCookie.ToObject>()["cookie"]; + private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary() { { "cookie", value } }, Converter.Settings)); + } + + [JsonIgnore] + public DateTime AccessTokenExpires + { + get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime; + private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d; + } + + [JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime(); + [JsonIgnore] public Locale Locale => Localization.Get(LocaleCode); + [JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber; + [JsonIgnore] public string DeviceType => DeviceInfo.DeviceType; + [JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId; + + public Task GetAccessTokenAsync() + => Task.FromResult(new AccessToken(AccessToken, AccessTokenExpires)); + + public Task GetAdpTokenAsync() + => Task.FromResult(new AdpToken(AdpToken)); + + public Task GetPrivateKeyAsync() + => Task.FromResult(new PrivateKey(DevicePrivateKey)); + } + + public partial class CustomerInfo + { + [JsonProperty("account_pool")] + public string AccountPool { get; set; } + + [JsonProperty("user_id")] + public string UserId { get; set; } + + [JsonProperty("home_region")] + public string HomeRegion { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("given_name")] + public string GivenName { get; set; } + } + + public partial class DeviceInfo + { + [JsonProperty("device_name")] + public string DeviceName { get; set; } + + [JsonProperty("device_serial_number")] + public string DeviceSerialNumber { get; set; } + + [JsonProperty("device_type")] + public string DeviceType { get; set; } + } + + public partial class Mkb79Auth + { + public static Mkb79Auth FromJson(string json) + => JsonConvert.DeserializeObject(json, Converter.Settings); + + public string ToJson() + => JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented); + + public async Task ToAccountAsync() + { + var refreshToken = new RefreshToken(RefreshToken); + + var authorize = new Authorize(Locale); + var newToken = await authorize.RefreshAccessTokenAsync(refreshToken); + AccessToken = newToken.TokenValue; + AccessTokenExpires = newToken.Expires; + + var api = new Api(this); + var email = await api.GetEmailAsync(); + var account = new Account(email) + { + DecryptKey = ActivationBytes, + AccountName = $"{email} - {Locale.Name}", + IdentityTokens = new Identity(Locale) + }; + + account.IdentityTokens.Update( + await GetPrivateKeyAsync(), + await GetAdpTokenAsync(), + await GetAccessTokenAsync(), + refreshToken, + WebsiteCookies.Select(c => new KeyValuePair(c.Key, c.Value)), + DeviceSerialNumber, + DeviceType, + AmazonAccountId, + DeviceInfo.DeviceName, + StoreAuthenticationCookie); + + return account; + } + + public static Mkb79Auth FromAccount(Account account) + => new() + { + AccessToken = account.IdentityTokens.ExistingAccessToken.TokenValue, + ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey, + AdpToken = account.IdentityTokens.AdpToken.Value, + CustomerInfo = new CustomerInfo + { + AccountPool = "Amazon", + GivenName = string.Empty, + HomeRegion = "NA", + Name = string.Empty, + UserId = account.IdentityTokens.AmazonAccountId + }, + DeviceInfo = new DeviceInfo + { + DeviceName = account.IdentityTokens.DeviceName, + DeviceSerialNumber = account.IdentityTokens.DeviceSerialNumber, + DeviceType = account.IdentityTokens.DeviceType, + }, + DevicePrivateKey = account.IdentityTokens.PrivateKey, + AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires, + LocaleCode = account.Locale.CountryCode, + RefreshToken = account.IdentityTokens.RefreshToken.Value, + StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie, + WebsiteCookies = new(account.IdentityTokens.Cookies.ToKeyValuePair()), + }; + } + + public static class Serialize + { + public static string ToJson(this Mkb79Auth self) + => JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented); + } + + internal static class Converter + { + public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings + { + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + DateParseHandling = DateParseHandling.None, + }; + } +} diff --git a/Source/DataLayer/QueryObjects/BookQueries.cs b/Source/DataLayer/QueryObjects/BookQueries.cs index efcbdc36..85f9153b 100644 --- a/Source/DataLayer/QueryObjects/BookQueries.cs +++ b/Source/DataLayer/QueryObjects/BookQueries.cs @@ -44,6 +44,8 @@ namespace DataLayer public static bool IsEpisodeParent(this Book book) => book.ContentType is ContentType.Parent; - + public static bool HasLiberated(this Book book) + => book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated || + book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated; } } diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index 9c60f3fb..2dbd0ddc 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -43,18 +43,37 @@ namespace DataLayer .Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor) .Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory); + public static IEnumerable ParentedEpisodes(this IEnumerable libraryBooks) + => libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(s => libraryBooks.FindChildren(s)); + + public static IEnumerable FindOrphanedEpisodes(this IEnumerable libraryBooks) + => libraryBooks + .Where(lb => lb.Book.IsEpisodeChild()) + .ExceptBy( + libraryBooks + .ParentedEpisodes() + .Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId); + #nullable enable public static LibraryBook? FindSeriesParent(this IEnumerable libraryBooks, LibraryBook seriesEpisode) { if (seriesEpisode.Book.SeriesLink is null) return null; - //Parent books will always have exactly 1 SeriesBook due to how - //they are imported in ApiExtended.getChildEpisodesAsync() - return libraryBooks.FirstOrDefault( - lb => - lb.Book.IsEpisodeParent() && - seriesEpisode.Book.SeriesLink.Any( - s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId)); + try + { + //Parent books will always have exactly 1 SeriesBook due to how + //they are imported in ApiExtended.getChildEpisodesAsync() + return libraryBooks.FirstOrDefault( + lb => + lb.Book.IsEpisodeParent() && + seriesEpisode.Book.SeriesLink.Any( + s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId)); + } + catch (System.Exception ex) + { + Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent)); + return null; + } } #nullable disable diff --git a/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs b/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs index ed64f90e..4cfa4680 100644 --- a/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs @@ -31,7 +31,9 @@ this.cancelBtn = new System.Windows.Forms.Button(); this.saveBtn = new System.Windows.Forms.Button(); this.dataGridView1 = new System.Windows.Forms.DataGridView(); + this.importBtn = new System.Windows.Forms.Button(); this.DeleteAccount = new System.Windows.Forms.DataGridViewButtonColumn(); + this.ExportAccount = new System.Windows.Forms.DataGridViewButtonColumn(); this.LibraryScan = new System.Windows.Forms.DataGridViewCheckBoxColumn(); this.AccountId = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.Locale = new System.Windows.Forms.DataGridViewComboBoxColumn(); @@ -43,9 +45,10 @@ // this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel; - this.cancelBtn.Location = new System.Drawing.Point(713, 415); + this.cancelBtn.Location = new System.Drawing.Point(832, 479); + this.cancelBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.cancelBtn.Name = "cancelBtn"; - this.cancelBtn.Size = new System.Drawing.Size(75, 23); + this.cancelBtn.Size = new System.Drawing.Size(88, 27); this.cancelBtn.TabIndex = 2; this.cancelBtn.Text = "Cancel"; this.cancelBtn.UseVisualStyleBackColor = true; @@ -54,9 +57,10 @@ // saveBtn // this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.saveBtn.Location = new System.Drawing.Point(612, 415); + this.saveBtn.Location = new System.Drawing.Point(714, 479); + this.saveBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.saveBtn.Name = "saveBtn"; - this.saveBtn.Size = new System.Drawing.Size(75, 23); + this.saveBtn.Size = new System.Drawing.Size(88, 27); this.saveBtn.TabIndex = 1; this.saveBtn.Text = "Save"; this.saveBtn.UseVisualStyleBackColor = true; @@ -71,60 +75,83 @@ this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; this.dataGridView1.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { this.DeleteAccount, + this.ExportAccount, this.LibraryScan, this.AccountId, this.Locale, this.AccountName}); - this.dataGridView1.Location = new System.Drawing.Point(12, 12); + this.dataGridView1.Location = new System.Drawing.Point(14, 14); + this.dataGridView1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.dataGridView1.MultiSelect = false; this.dataGridView1.Name = "dataGridView1"; - this.dataGridView1.Size = new System.Drawing.Size(776, 397); + this.dataGridView1.Size = new System.Drawing.Size(905, 458); this.dataGridView1.TabIndex = 0; this.dataGridView1.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView1_CellContentClick); this.dataGridView1.DefaultValuesNeeded += new System.Windows.Forms.DataGridViewRowEventHandler(this.dataGridView1_DefaultValuesNeeded); // + // importBtn + // + this.importBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.importBtn.Location = new System.Drawing.Point(14, 480); + this.importBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + this.importBtn.Name = "importBtn"; + this.importBtn.Size = new System.Drawing.Size(156, 27); + this.importBtn.TabIndex = 1; + this.importBtn.Text = "Import from audible-cli"; + this.importBtn.UseVisualStyleBackColor = true; + this.importBtn.Click += new System.EventHandler(this.importBtn_Click); + // // DeleteAccount // this.DeleteAccount.HeaderText = "Delete"; this.DeleteAccount.Name = "DeleteAccount"; this.DeleteAccount.ReadOnly = true; this.DeleteAccount.Text = "x"; - this.DeleteAccount.Width = 44; + this.DeleteAccount.Width = 46; + // + // ExportAccount + // + this.ExportAccount.HeaderText = "Export"; + this.ExportAccount.Name = "ExportAccount"; + this.ExportAccount.Text = "Export to audible-cli"; + this.ExportAccount.Width = 47; // // LibraryScan // this.LibraryScan.HeaderText = "Include in library scan?"; this.LibraryScan.Name = "LibraryScan"; - this.LibraryScan.Width = 83; + this.LibraryScan.Width = 94; // // AccountId // this.AccountId.HeaderText = "Audible email/login"; this.AccountId.Name = "AccountId"; - this.AccountId.Width = 111; + this.AccountId.Width = 125; // // Locale // this.Locale.HeaderText = "Locale"; this.Locale.Name = "Locale"; - this.Locale.Width = 45; + this.Locale.Width = 47; // // AccountName // this.AccountName.HeaderText = "Account nickname (optional)"; this.AccountName.Name = "AccountName"; - this.AccountName.Width = 152; + this.AccountName.Width = 170; // // AccountsDialog // this.AcceptButton = this.saveBtn; - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.CancelButton = this.cancelBtn; - this.ClientSize = new System.Drawing.Size(800, 450); + this.ClientSize = new System.Drawing.Size(933, 519); this.Controls.Add(this.dataGridView1); + this.Controls.Add(this.importBtn); this.Controls.Add(this.saveBtn); this.Controls.Add(this.cancelBtn); + this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.Name = "AccountsDialog"; this.Text = "Audible Accounts"; ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit(); @@ -137,7 +164,9 @@ private System.Windows.Forms.Button cancelBtn; private System.Windows.Forms.Button saveBtn; private System.Windows.Forms.DataGridView dataGridView1; + private System.Windows.Forms.Button importBtn; private System.Windows.Forms.DataGridViewButtonColumn DeleteAccount; + private System.Windows.Forms.DataGridViewButtonColumn ExportAccount; private System.Windows.Forms.DataGridViewCheckBoxColumn LibraryScan; private System.Windows.Forms.DataGridViewTextBoxColumn AccountId; private System.Windows.Forms.DataGridViewComboBoxColumn Locale; diff --git a/Source/LibationWinForms/Dialogs/AccountsDialog.cs b/Source/LibationWinForms/Dialogs/AccountsDialog.cs index e2a90e2a..0f04bb7c 100644 --- a/Source/LibationWinForms/Dialogs/AccountsDialog.cs +++ b/Source/LibationWinForms/Dialogs/AccountsDialog.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Windows.Forms; using AudibleApi; @@ -10,6 +11,7 @@ namespace LibationWinForms.Dialogs public partial class AccountsDialog : Form { private const string COL_Delete = nameof(DeleteAccount); + private const string COL_Export = nameof(ExportAccount); private const string COL_LibraryScan = nameof(LibraryScan); private const string COL_AccountId = nameof(AccountId); private const string COL_AccountName = nameof(AccountName); @@ -44,12 +46,20 @@ namespace LibationWinForms.Dialogs return; foreach (var account in accounts) - dataGridView1.Rows.Add( + AddAccountToGrid(account); + } + + private void AddAccountToGrid(Account account) + { + int row = dataGridView1.Rows.Add( "X", + "Export", account.LibraryScan, account.AccountId, account.Locale.Name, account.AccountName); + + dataGridView1[COL_Export, row].ToolTipText = "Export account authorization to audible-cli"; } private void dataGridView1_DefaultValuesNeeded(object sender, DataGridViewRowEventArgs e) @@ -73,6 +83,11 @@ namespace LibationWinForms.Dialogs if (e.RowIndex < dgv.RowCount - 1) dgv.Rows.Remove(row); break; + case COL_Export: + // if final/edit row: do nothing + if (e.RowIndex < dgv.RowCount - 1) + Export((string)row.Cells[COL_AccountId].Value, (string)row.Cells[COL_Locale].Value); + break; //case COL_MoveUp: // // if top: do nothing // if (e.RowIndex < 1) @@ -136,13 +151,13 @@ namespace LibationWinForms.Dialogs { if (string.IsNullOrWhiteSpace(dto.AccountId)) { - MessageBox.Show("Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error); + MessageBox.Show(this, "Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error); return false; } if (string.IsNullOrWhiteSpace(dto.LocaleName)) { - MessageBox.Show("Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error); + MessageBox.Show(this, "Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error); return false; } } @@ -194,5 +209,95 @@ namespace LibationWinForms.Dialogs LibraryScan = (bool)r.Cells[COL_LibraryScan].Value }) .ToList(); + + private string GetAudibleCliAppDataPath() + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audible"); + + private void Export(string accountId, string locale) + { + // without transaction, accounts persister will write ANY EDIT immediately to file + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + + var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == accountId && a.Locale.Name == locale); + + if (account is null) + return; + + if (account.IdentityTokens?.IsValid != true) + { + MessageBox.Show(this, "This account hasn't been authenticated yet. First scan your library to log into your account, then try exporting again.", "Account Not Authenticated"); + return; + } + + SaveFileDialog sfd = new(); + sfd.Filter = "JSON File|*.json"; + + string audibleAppDataDir = GetAudibleCliAppDataPath(); + + if (Directory.Exists(audibleAppDataDir)) + sfd.InitialDirectory = audibleAppDataDir; + + if (sfd.ShowDialog() != DialogResult.OK) return; + + try + { + var mkbAuth = Mkb79Auth.FromAccount(account); + var jsonText = mkbAuth.ToJson(); + + File.WriteAllText(sfd.FileName, jsonText); + + MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{sfd.FileName}", "Success!"); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + this, + $"An error occured while exporting account:\r\n{account.AccountName}", + "Error Exporting Account", + ex); + } + } + + private async void importBtn_Click(object sender, EventArgs e) + { + OpenFileDialog ofd = new(); + ofd.Filter = "JSON File|*.json"; + ofd.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + + string audibleAppDataDir = GetAudibleCliAppDataPath(); + + if (Directory.Exists(audibleAppDataDir)) + ofd.InitialDirectory = audibleAppDataDir; + + if (ofd.ShowDialog() != DialogResult.OK) return; + + try + { + var jsonText = File.ReadAllText(ofd.FileName); + var mkbAuth = Mkb79Auth.FromJson(jsonText); + var account = await mkbAuth.ToAccountAsync(); + + // without transaction, accounts persister will write ANY EDIT immediately to file + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + + if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens.Locale.Name == account.Locale.Name)) + { + MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale.Name}", "Cannot Add Duplicate Account"); + return; + } + + persister.AccountsSettings.Add(account); + + AddAccountToGrid(account); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + this, + $"An error occured while importing an account from:\r\n{ofd.FileName}\r\n\r\nIs the file encrypted?", + "Error Importing Account", + ex); + } + } } } diff --git a/Source/LibationWinForms/Dialogs/AccountsDialog.resx b/Source/LibationWinForms/Dialogs/AccountsDialog.resx index f1117452..d9e7d106 100644 --- a/Source/LibationWinForms/Dialogs/AccountsDialog.resx +++ b/Source/LibationWinForms/Dialogs/AccountsDialog.resx @@ -1,5 +1,4 @@ - - + @@ -58,10 +57,10 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + True - + True @@ -70,10 +69,10 @@ True - - True - True + + True + \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/RemoveBooksDialog.Designer.cs b/Source/LibationWinForms/Dialogs/RemoveBooksDialog.Designer.cs deleted file mode 100644 index 7240b8f3..00000000 --- a/Source/LibationWinForms/Dialogs/RemoveBooksDialog.Designer.cs +++ /dev/null @@ -1,189 +0,0 @@ - -namespace LibationWinForms.Dialogs -{ - partial class RemoveBooksDialog - { - /// - /// 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() - { - this.components = new System.ComponentModel.Container(); - System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle(); - this._dataGridView = new System.Windows.Forms.DataGridView(); - this.removeDataGridViewCheckBoxColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn(); - this.coverDataGridViewImageColumn = new System.Windows.Forms.DataGridViewImageColumn(); - this.titleDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - this.authorsDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - this.miscDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - this.purchaseDateGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); - this.gridEntryBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components); - this.btnRemoveBooks = new System.Windows.Forms.Button(); - this.label1 = new System.Windows.Forms.Label(); - ((System.ComponentModel.ISupportInitialize)(this._dataGridView)).BeginInit(); - ((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).BeginInit(); - this.SuspendLayout(); - // - // _dataGridView - // - this._dataGridView.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - this._dataGridView.AutoGenerateColumns = false; - this._dataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; - this._dataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { - this.removeDataGridViewCheckBoxColumn, - this.coverDataGridViewImageColumn, - this.titleDataGridViewTextBoxColumn, - this.authorsDataGridViewTextBoxColumn, - this.miscDataGridViewTextBoxColumn, - this.purchaseDateGridViewTextBoxColumn}); - this._dataGridView.DataSource = this.gridEntryBindingSource; - dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; - dataGridViewCellStyle1.BackColor = System.Drawing.SystemColors.Window; - dataGridViewCellStyle1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - dataGridViewCellStyle1.ForeColor = System.Drawing.SystemColors.ControlText; - dataGridViewCellStyle1.SelectionBackColor = System.Drawing.SystemColors.Highlight; - dataGridViewCellStyle1.SelectionForeColor = System.Drawing.SystemColors.HighlightText; - dataGridViewCellStyle1.WrapMode = System.Windows.Forms.DataGridViewTriState.True; - this._dataGridView.DefaultCellStyle = dataGridViewCellStyle1; - this._dataGridView.Location = new System.Drawing.Point(0, 0); - this._dataGridView.Name = "_dataGridView"; - this._dataGridView.RowHeadersVisible = false; - this._dataGridView.RowTemplate.Height = 82; - this._dataGridView.Size = new System.Drawing.Size(730, 409); - this._dataGridView.TabIndex = 0; - // - // removeDataGridViewCheckBoxColumn - // - this.removeDataGridViewCheckBoxColumn.DataPropertyName = "Remove"; - this.removeDataGridViewCheckBoxColumn.FalseValue = "False"; - this.removeDataGridViewCheckBoxColumn.Frozen = true; - this.removeDataGridViewCheckBoxColumn.HeaderText = "Remove"; - this.removeDataGridViewCheckBoxColumn.MinimumWidth = 80; - this.removeDataGridViewCheckBoxColumn.Name = "removeDataGridViewCheckBoxColumn"; - this.removeDataGridViewCheckBoxColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; - this.removeDataGridViewCheckBoxColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; - this.removeDataGridViewCheckBoxColumn.TrueValue = "True"; - this.removeDataGridViewCheckBoxColumn.Width = 80; - // - // coverDataGridViewImageColumn - // - this.coverDataGridViewImageColumn.DataPropertyName = "Cover"; - this.coverDataGridViewImageColumn.HeaderText = "Cover"; - this.coverDataGridViewImageColumn.MinimumWidth = 80; - this.coverDataGridViewImageColumn.Name = "coverDataGridViewImageColumn"; - this.coverDataGridViewImageColumn.ReadOnly = true; - this.coverDataGridViewImageColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; - this.coverDataGridViewImageColumn.Width = 80; - // - // titleDataGridViewTextBoxColumn - // - this.titleDataGridViewTextBoxColumn.DataPropertyName = "Title"; - this.titleDataGridViewTextBoxColumn.HeaderText = "Title"; - this.titleDataGridViewTextBoxColumn.Name = "titleDataGridViewTextBoxColumn"; - this.titleDataGridViewTextBoxColumn.ReadOnly = true; - this.titleDataGridViewTextBoxColumn.Width = 200; - // - // authorsDataGridViewTextBoxColumn - // - this.authorsDataGridViewTextBoxColumn.DataPropertyName = "Authors"; - this.authorsDataGridViewTextBoxColumn.HeaderText = "Authors"; - this.authorsDataGridViewTextBoxColumn.Name = "authorsDataGridViewTextBoxColumn"; - this.authorsDataGridViewTextBoxColumn.ReadOnly = true; - // - // miscDataGridViewTextBoxColumn - // - this.miscDataGridViewTextBoxColumn.DataPropertyName = "Misc"; - this.miscDataGridViewTextBoxColumn.HeaderText = "Misc"; - this.miscDataGridViewTextBoxColumn.Name = "miscDataGridViewTextBoxColumn"; - this.miscDataGridViewTextBoxColumn.ReadOnly = true; - this.miscDataGridViewTextBoxColumn.Width = 150; - // - // purchaseDateGridViewTextBoxColumn - // - this.purchaseDateGridViewTextBoxColumn.DataPropertyName = "PurchaseDate"; - this.purchaseDateGridViewTextBoxColumn.HeaderText = "Purchase Date"; - this.purchaseDateGridViewTextBoxColumn.Name = "purchaseDateGridViewTextBoxColumn"; - this.purchaseDateGridViewTextBoxColumn.ReadOnly = true; - this.purchaseDateGridViewTextBoxColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; - // - // gridEntryBindingSource - // - this.gridEntryBindingSource.AllowNew = false; - this.gridEntryBindingSource.DataSource = typeof(LibationWinForms.Dialogs.RemovableGridEntry); - // - // btnRemoveBooks - // - this.btnRemoveBooks.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.btnRemoveBooks.Location = new System.Drawing.Point(500, 419); - this.btnRemoveBooks.Name = "btnRemoveBooks"; - this.btnRemoveBooks.Size = new System.Drawing.Size(218, 23); - this.btnRemoveBooks.TabIndex = 1; - this.btnRemoveBooks.Text = "Remove Selected Books from Libation"; - this.btnRemoveBooks.UseVisualStyleBackColor = true; - this.btnRemoveBooks.Click += new System.EventHandler(this.btnRemoveBooks_Click); - // - // label1 - // - this.label1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.label1.AutoSize = true; - this.label1.Location = new System.Drawing.Point(12, 423); - this.label1.Name = "label1"; - this.label1.Size = new System.Drawing.Size(178, 15); - this.label1.TabIndex = 2; - this.label1.Text = "{0} book{1} selected for removal."; - // - // RemoveBooksDialog - // - this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(730, 450); - this.Controls.Add(this.label1); - this.Controls.Add(this.btnRemoveBooks); - this.Controls.Add(this._dataGridView); - this.Name = "RemoveBooksDialog"; - this.Text = "Remove Books from Libation's Database"; - this.Shown += new System.EventHandler(this.RemoveBooksDialog_Shown); - ((System.ComponentModel.ISupportInitialize)(this._dataGridView)).EndInit(); - ((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).EndInit(); - this.ResumeLayout(false); - this.PerformLayout(); - - } - - #endregion - - private System.Windows.Forms.DataGridView _dataGridView; - private LibationWinForms.GridView.SyncBindingSource gridEntryBindingSource; - private System.Windows.Forms.Button btnRemoveBooks; - private System.Windows.Forms.Label label1; - private System.Windows.Forms.DataGridViewCheckBoxColumn removeDataGridViewCheckBoxColumn; - private System.Windows.Forms.DataGridViewImageColumn coverDataGridViewImageColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn titleDataGridViewTextBoxColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn authorsDataGridViewTextBoxColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn miscDataGridViewTextBoxColumn; - private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGridViewTextBoxColumn; - } -} \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/RemoveBooksDialog.cs b/Source/LibationWinForms/Dialogs/RemoveBooksDialog.cs deleted file mode 100644 index 84e5ff51..00000000 --- a/Source/LibationWinForms/Dialogs/RemoveBooksDialog.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Data; -using System.Linq; -using System.Windows.Forms; -using ApplicationServices; -using AudibleUtilities; -using DataLayer; -using Dinah.Core.DataBinding; -using LibationFileManager; -using LibationWinForms.Login; - -namespace LibationWinForms.Dialogs -{ - public partial class RemoveBooksDialog : Form - { - private Account[] _accounts { get; } - private List _libraryBooks { get; } - private SortableBindingList _removableGridEntries { get; } - private string _labelFormat { get; } - private int SelectedCount => SelectedEntries?.Count() ?? 0; - private IEnumerable SelectedEntries => _removableGridEntries?.Where(b => b.Remove); - - public RemoveBooksDialog(params Account[] accounts) - { - _libraryBooks = DbContexts.GetLibrary_Flat_NoTracking(); - _accounts = accounts; - - InitializeComponent(); - - this.Load += (_, _) => this.RestoreSizeAndLocation(Configuration.Instance); - this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); - - _labelFormat = label1.Text; - - _dataGridView.CellContentClick += (_, _) => _dataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit); - _dataGridView.CellValueChanged += (_, _) => UpdateSelection(); - _dataGridView.BindingContextChanged += _dataGridView_BindingContextChanged; - - var orderedGridEntries = _libraryBooks - .Select(lb => new RemovableGridEntry(lb)) - .OrderByDescending(ge => (DateTime)ge.GetMemberValue(nameof(ge.PurchaseDate))) - .ToList(); - - _removableGridEntries = new SortableBindingList(orderedGridEntries); - gridEntryBindingSource.DataSource = _removableGridEntries; - - _dataGridView.Enabled = false; - this.SetLibationIcon(); - } - - private void _dataGridView_BindingContextChanged(object sender, EventArgs e) - { - _dataGridView.Sort(_dataGridView.Columns[0], ListSortDirection.Descending); - UpdateSelection(); - } - - private async void RemoveBooksDialog_Shown(object sender, EventArgs e) - { - if (_accounts is null || _accounts.Length == 0) - return; - try - { - var removedBooks = await LibraryCommands.FindInactiveBooks(WinformLoginChoiceEager.ApiExtendedFunc, _libraryBooks, _accounts); - - var removable = _removableGridEntries.Where(rge => removedBooks.Any(rb => rb.Book.AudibleProductId == rge.AudibleProductId)).ToList(); - - if (!removable.Any()) - return; - - foreach (var r in removable) - r.Remove = true; - - UpdateSelection(); - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert( - this, - "Error scanning library. You may still manually select books to remove from Libation's library.", - "Error scanning library", - ex); - } - finally - { - _dataGridView.Enabled = true; - } - } - - private async void btnRemoveBooks_Click(object sender, EventArgs e) - { - var selectedBooks = SelectedEntries.ToList(); - - if (selectedBooks.Count == 0) - return; - - var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); - var result = MessageBoxLib.ShowConfirmationDialog( - libraryBooks, - $"Are you sure you want to remove {0} from Libation's library?", - "Remove books from Libation?"); - - if (result != DialogResult.Yes) - return; - - var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); - var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); - - foreach (var rEntry in selectedBooks) - _removableGridEntries.Remove(rEntry); - - UpdateSelection(); - } - - private void UpdateSelection() - { - var selectedCount = SelectedCount; - label1.Text = string.Format(_labelFormat, selectedCount, selectedCount != 1 ? "s" : string.Empty); - btnRemoveBooks.Enabled = selectedCount > 0; - } - } - - internal class RemovableGridEntry : GridView.LibraryBookEntry - { - private bool _remove = false; - public RemovableGridEntry(LibraryBook libraryBook) : base(libraryBook) { } - - public bool Remove - { - get - { - return _remove; - } - set - { - _remove = value; - NotifyPropertyChanged(); - } - } - - public override object GetMemberValue(string memberName) - { - if (memberName == nameof(Remove)) - return Remove; - return base.GetMemberValue(memberName); - } - } -} diff --git a/Source/LibationWinForms/Dialogs/RemoveBooksDialog.resx b/Source/LibationWinForms/Dialogs/RemoveBooksDialog.resx deleted file mode 100644 index a3058bc8..00000000 --- a/Source/LibationWinForms/Dialogs/RemoveBooksDialog.resx +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=5.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=5.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - 17, 17 - - \ No newline at end of file diff --git a/Source/LibationWinForms/Form1.Designer.cs b/Source/LibationWinForms/Form1.Designer.cs index c670c6d3..4cc1c171 100644 --- a/Source/LibationWinForms/Form1.Designer.cs +++ b/Source/LibationWinForms/Form1.Designer.cs @@ -74,6 +74,8 @@ this.panel1 = new System.Windows.Forms.Panel(); this.productsDisplay = new LibationWinForms.GridView.ProductsDisplay(); this.toggleQueueHideBtn = new System.Windows.Forms.Button(); + this.doneRemovingBtn = new System.Windows.Forms.Button(); + this.removeBooksBtn = new System.Windows.Forms.Button(); this.processBookQueue1 = new LibationWinForms.ProcessQueue.ProcessQueueControl(); this.menuStrip1.SuspendLayout(); this.statusStrip1.SuspendLayout(); @@ -98,7 +100,7 @@ // filterBtn // this.filterBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); - this.filterBtn.Location = new System.Drawing.Point(916, 3); + this.filterBtn.Location = new System.Drawing.Point(892, 3); this.filterBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.filterBtn.Name = "filterBtn"; this.filterBtn.Size = new System.Drawing.Size(88, 27); @@ -111,10 +113,11 @@ // this.filterSearchTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); - this.filterSearchTb.Location = new System.Drawing.Point(196, 7); + this.filterSearchTb.Font = new System.Drawing.Font("Segoe UI", 10F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); + this.filterSearchTb.Location = new System.Drawing.Point(195, 5); this.filterSearchTb.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.filterSearchTb.Name = "filterSearchTb"; - this.filterSearchTb.Size = new System.Drawing.Size(712, 23); + this.filterSearchTb.Size = new System.Drawing.Size(689, 25); this.filterSearchTb.TabIndex = 1; this.filterSearchTb.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.filterSearchTb_KeyPress); // @@ -132,7 +135,7 @@ this.menuStrip1.Location = new System.Drawing.Point(0, 0); this.menuStrip1.Name = "menuStrip1"; this.menuStrip1.Padding = new System.Windows.Forms.Padding(7, 2, 0, 2); - this.menuStrip1.Size = new System.Drawing.Size(1061, 24); + this.menuStrip1.Size = new System.Drawing.Size(1037, 24); this.menuStrip1.TabIndex = 0; this.menuStrip1.Text = "menuStrip1"; // @@ -396,7 +399,8 @@ this.statusStrip1.Location = new System.Drawing.Point(0, 618); this.statusStrip1.Name = "statusStrip1"; this.statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0); - this.statusStrip1.Size = new System.Drawing.Size(1061, 22); + this.statusStrip1.ShowItemToolTips = true; + this.statusStrip1.Size = new System.Drawing.Size(1037, 22); this.statusStrip1.TabIndex = 6; this.statusStrip1.Text = "statusStrip1"; // @@ -410,7 +414,7 @@ // springLbl // this.springLbl.Name = "springLbl"; - this.springLbl.Size = new System.Drawing.Size(547, 17); + this.springLbl.Size = new System.Drawing.Size(523, 17); this.springLbl.Spring = true; // // backupsCountsLbl @@ -440,6 +444,7 @@ // splitContainer1 // this.splitContainer1.Dock = System.Windows.Forms.DockStyle.Fill; + this.splitContainer1.FixedPanel = System.Windows.Forms.FixedPanel.Panel2; this.splitContainer1.Location = new System.Drawing.Point(0, 0); this.splitContainer1.Name = "splitContainer1"; // @@ -453,7 +458,7 @@ // this.splitContainer1.Panel2.Controls.Add(this.processBookQueue1); this.splitContainer1.Size = new System.Drawing.Size(1463, 640); - this.splitContainer1.SplitterDistance = 1061; + this.splitContainer1.SplitterDistance = 1037; this.splitContainer1.SplitterWidth = 8; this.splitContainer1.TabIndex = 7; // @@ -462,6 +467,8 @@ this.panel1.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; this.panel1.Controls.Add(this.productsDisplay); this.panel1.Controls.Add(this.toggleQueueHideBtn); + this.panel1.Controls.Add(this.doneRemovingBtn); + this.panel1.Controls.Add(this.removeBooksBtn); this.panel1.Controls.Add(this.addQuickFilterBtn); this.panel1.Controls.Add(this.filterHelpBtn); this.panel1.Controls.Add(this.filterSearchTb); @@ -470,7 +477,7 @@ this.panel1.Location = new System.Drawing.Point(0, 24); this.panel1.Margin = new System.Windows.Forms.Padding(0); this.panel1.Name = "panel1"; - this.panel1.Size = new System.Drawing.Size(1061, 594); + this.panel1.Size = new System.Drawing.Size(1037, 594); this.panel1.TabIndex = 7; // // productsDisplay @@ -482,16 +489,17 @@ this.productsDisplay.Location = new System.Drawing.Point(15, 36); this.productsDisplay.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); this.productsDisplay.Name = "productsDisplay"; - this.productsDisplay.Size = new System.Drawing.Size(1031, 555); + this.productsDisplay.Size = new System.Drawing.Size(1007, 555); this.productsDisplay.TabIndex = 9; 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.InitialLoaded += new System.EventHandler(this.productsDisplay_InitialLoaded); // // toggleQueueHideBtn // this.toggleQueueHideBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); - this.toggleQueueHideBtn.Location = new System.Drawing.Point(1013, 3); + this.toggleQueueHideBtn.Location = new System.Drawing.Point(989, 3); this.toggleQueueHideBtn.Margin = new System.Windows.Forms.Padding(4, 3, 15, 3); this.toggleQueueHideBtn.Name = "toggleQueueHideBtn"; this.toggleQueueHideBtn.Size = new System.Drawing.Size(33, 27); @@ -500,6 +508,31 @@ this.toggleQueueHideBtn.UseVisualStyleBackColor = true; this.toggleQueueHideBtn.Click += new System.EventHandler(this.ToggleQueueHideBtn_Click); // + // doneRemovingBtn + // + this.doneRemovingBtn.Location = new System.Drawing.Point(406, 3); + this.doneRemovingBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + this.doneRemovingBtn.Name = "doneRemovingBtn"; + this.doneRemovingBtn.Size = new System.Drawing.Size(145, 27); + this.doneRemovingBtn.TabIndex = 4; + this.doneRemovingBtn.Text = "Done Removing Books"; + this.doneRemovingBtn.UseVisualStyleBackColor = true; + this.doneRemovingBtn.Visible = false; + this.doneRemovingBtn.Click += new System.EventHandler(this.doneRemovingBtn_Click); + // + // removeBooksBtn + // + this.removeBooksBtn.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point); + this.removeBooksBtn.Location = new System.Drawing.Point(206, 3); + this.removeBooksBtn.Margin = new System.Windows.Forms.Padding(15, 3, 4, 3); + this.removeBooksBtn.Name = "removeBooksBtn"; + this.removeBooksBtn.Size = new System.Drawing.Size(192, 27); + this.removeBooksBtn.TabIndex = 4; + this.removeBooksBtn.Text = "Remove # Books from Libation"; + this.removeBooksBtn.UseVisualStyleBackColor = true; + this.removeBooksBtn.Visible = false; + this.removeBooksBtn.Click += new System.EventHandler(this.removeBooksBtn_Click); + // // processBookQueue1 // this.processBookQueue1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; @@ -507,7 +540,7 @@ this.processBookQueue1.Location = new System.Drawing.Point(0, 0); this.processBookQueue1.Margin = new System.Windows.Forms.Padding(3, 4, 3, 4); this.processBookQueue1.Name = "processBookQueue1"; - this.processBookQueue1.Size = new System.Drawing.Size(394, 640); + this.processBookQueue1.Size = new System.Drawing.Size(418, 640); this.processBookQueue1.TabIndex = 0; // // Form1 @@ -584,5 +617,7 @@ private System.Windows.Forms.Panel panel1; private System.Windows.Forms.Button toggleQueueHideBtn; private LibationWinForms.GridView.ProductsDisplay productsDisplay; + private System.Windows.Forms.Button removeBooksBtn; + private System.Windows.Forms.Button doneRemovingBtn; } } diff --git a/Source/LibationWinForms/Form1.ProcessQueue.cs b/Source/LibationWinForms/Form1.ProcessQueue.cs index 8a8178f2..806d43c0 100644 --- a/Source/LibationWinForms/Form1.ProcessQueue.cs +++ b/Source/LibationWinForms/Form1.ProcessQueue.cs @@ -17,7 +17,9 @@ namespace LibationWinForms processBookQueue1.popoutBtn.Click += ProcessBookQueue1_PopOut; var coppalseState = Configuration.Instance.GetNonString(nameof(splitContainer1.Panel2Collapsed)); WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth; + int width = this.Width; SetQueueCollapseState(coppalseState); + this.Width = width; } private void ProductsDisplay_LiberateClicked(object sender, LibraryBook e) diff --git a/Source/LibationWinForms/Form1.RemoveBooks.cs b/Source/LibationWinForms/Form1.RemoveBooks.cs new file mode 100644 index 00000000..4a5753e8 --- /dev/null +++ b/Source/LibationWinForms/Form1.RemoveBooks.cs @@ -0,0 +1,92 @@ +using AudibleUtilities; +using LibationWinForms.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace LibationWinForms +{ + public partial class Form1 + { + public void Configure_RemoveBooks() { } + + private async void removeBooksBtn_Click(object sender, EventArgs e) + => await productsDisplay.RemoveCheckedBooksAsync(); + + private void doneRemovingBtn_Click(object sender, EventArgs e) + { + removeBooksBtn.Visible = false; + doneRemovingBtn.Visible = false; + + productsDisplay.CloseRemoveBooksColumn(); + + //Restore the filter + filterSearchTb.Enabled = true; + filterSearchTb.Visible = true; + performFilter(filterSearchTb.Text); + } + + private void removeLibraryBooksToolStripMenuItem_Click(object sender, EventArgs e) + { + // if 0 accounts, this will not be visible + // if 1 account, run scanLibrariesRemovedBooks() on this account + // if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks() + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings.GetAll(); + + if (accounts.Count != 1) + return; + + var firstAccount = accounts.Single(); + scanLibrariesRemovedBooks(firstAccount); + } + + // selectively remove books from all accounts + private void removeAllAccountsToolStripMenuItem_Click(object sender, EventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var allAccounts = persister.AccountsSettings.GetAll(); + scanLibrariesRemovedBooks(allAccounts.ToArray()); + } + + // selectively remove books from some accounts + private void removeSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e) + { + using var scanAccountsDialog = new ScanAccountsDialog(); + + if (scanAccountsDialog.ShowDialog() != DialogResult.OK) + return; + + if (!scanAccountsDialog.CheckedAccounts.Any()) + return; + + scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray()); + } + + private async void scanLibrariesRemovedBooks(params Account[] accounts) + { + //This action is meant to operate on the entire library. + //For removing books within a filter set, use + //Visible Books > Remove from library + filterSearchTb.Enabled = false; + filterSearchTb.Visible = false; + productsDisplay.Filter(null); + + removeBooksBtn.Visible = true; + doneRemovingBtn.Visible = true; + await productsDisplay.ScanAndRemoveBooksAsync(accounts); + } + + private void productsDisplay_RemovableCountChanged(object sender, int removeCount) + { + removeBooksBtn.Text = removeCount switch + { + 1 => "Remove 1 Book from Libation", + _ => $"Remove {removeCount} Books from Libation" + }; + } + } +} diff --git a/Source/LibationWinForms/Form1.ScanManual.cs b/Source/LibationWinForms/Form1.ScanManual.cs index 4b9215d9..b90c8826 100644 --- a/Source/LibationWinForms/Form1.ScanManual.cs +++ b/Source/LibationWinForms/Form1.ScanManual.cs @@ -67,50 +67,7 @@ namespace LibationWinForms return; await scanLibrariesAsync(scanAccountsDialog.CheckedAccounts); - } - - private void removeLibraryBooksToolStripMenuItem_Click(object sender, EventArgs e) - { - // if 0 accounts, this will not be visible - // if 1 account, run scanLibrariesRemovedBooks() on this account - // if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks() - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var accounts = persister.AccountsSettings.GetAll(); - - if (accounts.Count != 1) - return; - - var firstAccount = accounts.Single(); - scanLibrariesRemovedBooks(firstAccount); - } - - // selectively remove books from all accounts - private void removeAllAccountsToolStripMenuItem_Click(object sender, EventArgs e) - { - using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - var allAccounts = persister.AccountsSettings.GetAll(); - scanLibrariesRemovedBooks(allAccounts.ToArray()); - } - - // selectively remove books from some accounts - private void removeSomeAccountsToolStripMenuItem_Click(object sender, EventArgs e) - { - using var scanAccountsDialog = new ScanAccountsDialog(); - - if (scanAccountsDialog.ShowDialog() != DialogResult.OK) - return; - - if (!scanAccountsDialog.CheckedAccounts.Any()) - return; - - scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray()); - } - - private void scanLibrariesRemovedBooks(params Account[] accounts) - { - using var dialog = new RemoveBooksDialog(accounts); - dialog.ShowDialog(); - } + } private async Task scanLibrariesAsync(IEnumerable accounts) => await scanLibrariesAsync(accounts.ToArray()); private async Task scanLibrariesAsync(params Account[] accounts) diff --git a/Source/LibationWinForms/Form1.ScanNotification.cs b/Source/LibationWinForms/Form1.ScanNotification.cs index d6c5fa10..dc70537d 100644 --- a/Source/LibationWinForms/Form1.ScanNotification.cs +++ b/Source/LibationWinForms/Form1.ScanNotification.cs @@ -14,6 +14,9 @@ namespace LibationWinForms private void LibraryCommands_ScanBegin(object sender, int accountsLength) { + removeLibraryBooksToolStripMenuItem.Enabled = false; + removeAllAccountsToolStripMenuItem.Enabled = false; + removeSomeAccountsToolStripMenuItem.Enabled = false; scanLibraryToolStripMenuItem.Enabled = false; scanLibraryOfAllAccountsToolStripMenuItem.Enabled = false; scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = false; @@ -27,6 +30,9 @@ namespace LibationWinForms private void LibraryCommands_ScanEnd(object sender, EventArgs e) { + removeLibraryBooksToolStripMenuItem.Enabled = true; + removeAllAccountsToolStripMenuItem.Enabled = true; + removeSomeAccountsToolStripMenuItem.Enabled = true; scanLibraryToolStripMenuItem.Enabled = true; scanLibraryOfAllAccountsToolStripMenuItem.Enabled = true; scanLibraryOfSomeAccountsToolStripMenuItem.Enabled = true; diff --git a/Source/LibationWinForms/Form1.cs b/Source/LibationWinForms/Form1.cs index 8eb15705..ae4628a4 100644 --- a/Source/LibationWinForms/Form1.cs +++ b/Source/LibationWinForms/Form1.cs @@ -20,7 +20,7 @@ namespace LibationWinForms // Before calling anything else, including subscribing to events, ensure database exists. If we wait and let it happen lazily, race conditions and errors are likely during new installs using var _ = DbContexts.GetContext(); - this.Load += (_, _) => this.RestoreSizeAndLocation(Configuration.Instance); + this.RestoreSizeAndLocation(Configuration.Instance); this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); // this looks like a perfect opportunity to refactor per below. @@ -44,6 +44,7 @@ namespace LibationWinForms Configure_VisibleBooks(); Configure_QuickFilters(); Configure_ScanManual(); + Configure_RemoveBooks(); Configure_Liberate(); Configure_Export(); Configure_Settings(); diff --git a/Source/LibationWinForms/Form1.resx b/Source/LibationWinForms/Form1.resx index 2505fa27..f7ebef40 100644 --- a/Source/LibationWinForms/Form1.resx +++ b/Source/LibationWinForms/Form1.resx @@ -93,6 +93,12 @@ True + + True + + + True + True diff --git a/Source/LibationWinForms/GridView/GridEntry.cs b/Source/LibationWinForms/GridView/GridEntry.cs index 8e5702e1..a9a27c3a 100644 --- a/Source/LibationWinForms/GridView/GridEntry.cs +++ b/Source/LibationWinForms/GridView/GridEntry.cs @@ -12,6 +12,12 @@ using System.Linq; namespace LibationWinForms.GridView { + public enum RemoveStatus + { + NotRemoved, + Removed, + SomeRemoved + } /// The View Model base for the DataGridView public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable { @@ -24,6 +30,9 @@ namespace LibationWinForms.GridView #region Model properties exposed to the view + protected RemoveStatus _remove = RemoveStatus.NotRemoved; + public abstract RemoveStatus Remove { get; set; } + public abstract LiberateButtonStatus Liberate { get; } public Image Cover { diff --git a/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs b/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs index 1984587d..6e696cb8 100644 --- a/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs +++ b/Source/LibationWinForms/GridView/LiberateDataGridViewImageButtonColumn.cs @@ -19,18 +19,19 @@ namespace LibationWinForms.GridView private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230); 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) { - base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); - if (value is LiberateButtonStatus status) { + if (status.BookStatus is LiberatedStatus.Error) + paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground; + if (rowIndex >= 0 && DataGridView.GetBoundItem(rowIndex) is LibraryBookEntry lbEntry && lbEntry.Parent is not null) - { DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = SERIES_BG_COLOR; - } + + base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); if (status.IsSeries) { - DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus: Properties.Resources.plus, cellBounds); + DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus : Properties.Resources.plus, cellBounds); ToolTipText = status.Expanded ? "Click to Collpase" : "Click to Expand"; } @@ -48,7 +49,7 @@ namespace LibationWinForms.GridView private static (string mouseoverText, Bitmap buttonImage) GetLiberateDisplay(LiberatedStatus liberatedStatus, LiberatedStatus? pdfStatus) { if (liberatedStatus == LiberatedStatus.Error) - return ("Book downloaded ERROR", SystemIcons.Error.ToBitmap()); + return ("Book downloaded ERROR", Properties.Resources.error); (string libState, string image_lib) = liberatedStatus switch { diff --git a/Source/LibationWinForms/GridView/LibraryBookEntry.cs b/Source/LibationWinForms/GridView/LibraryBookEntry.cs index 861b9587..85e6c687 100644 --- a/Source/LibationWinForms/GridView/LibraryBookEntry.cs +++ b/Source/LibationWinForms/GridView/LibraryBookEntry.cs @@ -20,6 +20,20 @@ namespace LibationWinForms.GridView private LiberatedStatus _bookStatus; private LiberatedStatus? _pdfStatus; + public override RemoveStatus Remove + { + get + { + return _remove; + } + set + { + _remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value; + Parent?.ChildRemoveUpdate(); + NotifyPropertyChanged(); + } + } + public override LiberateButtonStatus Liberate { get diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs index db9ca6a5..b84d670c 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs @@ -39,11 +39,12 @@ 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.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.VisibleCountChanged += new System.EventHandler(this.productsGrid_VisibleCountChanged); + this.productsGrid.RemovableCountChanged += new System.EventHandler(this.productsGrid_RemovableCountChanged); // // ProductsDisplay // diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 7eb55455..e7210ad0 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -1,4 +1,5 @@ using ApplicationServices; +using AudibleUtilities; using DataLayer; using FileLiberator; using LibationFileManager; @@ -16,6 +17,7 @@ namespace LibationWinForms.GridView { /// Number of visible rows has changed public event EventHandler VisibleCountChanged; + public event EventHandler RemovableCountChanged; public event EventHandler LiberateClicked; public event EventHandler InitialLoaded; @@ -80,7 +82,69 @@ namespace LibationWinForms.GridView #endregion - #region UI display functions + #region Scan and Remove Books + + public void CloseRemoveBooksColumn() + => productsGrid.RemoveColumnVisible = false; + + public async Task RemoveCheckedBooksAsync() + { + var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is RemoveStatus.Removed).ToList(); + + if (selectedBooks.Count == 0) + return; + + var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); + var result = MessageBoxLib.ShowConfirmationDialog( + libraryBooks, + $"Are you sure you want to remove {selectedBooks.Count} books from Libation's library?", + "Remove books from Libation?"); + + if (result != DialogResult.Yes) + return; + + productsGrid.RemoveBooks(selectedBooks); + var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); + var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); + } + + public async Task ScanAndRemoveBooksAsync(params Account[] accounts) + { + RemovableCountChanged?.Invoke(this, 0); + productsGrid.RemoveColumnVisible = true; + + try + { + if (accounts is null || accounts.Length == 0) + return; + + var allBooks = productsGrid.GetAllBookEntries(); + var lib = allBooks + .Select(lbe => lbe.LibraryBook) + .Where(lb => !lb.Book.HasLiberated()); + + var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.ApiExtendedFunc, lib, accounts); + + var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); + + foreach (var r in removable) + r.Remove = RemoveStatus.Removed; + + productsGrid_RemovableCountChanged(this, null); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + this, + "Error scanning library. You may still manually select books to remove from Libation's library.", + "Error scanning library", + ex); + } + } + + #endregion + + #region UI display functions public void Display() { @@ -123,7 +187,13 @@ namespace LibationWinForms.GridView private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry) { - LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook); + if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error) + LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook); + } + + private void productsGrid_RemovableCountChanged(object sender, EventArgs e) + { + RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is RemoveStatus.Removed)); } } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs index b380ff95..bca1dec0 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs @@ -29,8 +29,9 @@ private void InitializeComponent() { this.components = new System.ComponentModel.Container(); - System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle2 = new System.Windows.Forms.DataGridViewCellStyle(); + System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle(); this.gridEntryDataGridView = new System.Windows.Forms.DataGridView(); + this.removeGVColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn(); this.liberateGVColumn = new LibationWinForms.GridView.LiberateDataGridViewImageButtonColumn(); this.coverGVColumn = new System.Windows.Forms.DataGridViewImageColumn(); this.titleGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); @@ -60,41 +61,56 @@ this.gridEntryDataGridView.AutoGenerateColumns = false; this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { - this.liberateGVColumn, - this.coverGVColumn, - this.titleGVColumn, - this.authorsGVColumn, - this.narratorsGVColumn, - this.lengthGVColumn, - this.seriesGVColumn, - this.descriptionGVColumn, - this.categoryGVColumn, - this.productRatingGVColumn, - this.purchaseDateGVColumn, - this.myRatingGVColumn, - this.miscGVColumn, - this.tagAndDetailsGVColumn}); + this.removeGVColumn, + this.liberateGVColumn, + this.coverGVColumn, + this.titleGVColumn, + this.authorsGVColumn, + this.narratorsGVColumn, + this.lengthGVColumn, + this.seriesGVColumn, + this.descriptionGVColumn, + this.categoryGVColumn, + this.productRatingGVColumn, + this.purchaseDateGVColumn, + this.myRatingGVColumn, + this.miscGVColumn, + this.tagAndDetailsGVColumn}); this.gridEntryDataGridView.ContextMenuStrip = this.contextMenuStrip1; this.gridEntryDataGridView.DataSource = this.syncBindingSource; - dataGridViewCellStyle2.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; - dataGridViewCellStyle2.BackColor = System.Drawing.SystemColors.Window; - dataGridViewCellStyle2.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); - dataGridViewCellStyle2.ForeColor = System.Drawing.SystemColors.ControlText; - dataGridViewCellStyle2.SelectionBackColor = System.Drawing.SystemColors.Highlight; - dataGridViewCellStyle2.SelectionForeColor = System.Drawing.SystemColors.HighlightText; - dataGridViewCellStyle2.WrapMode = System.Windows.Forms.DataGridViewTriState.True; - this.gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle2; + dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; + dataGridViewCellStyle1.BackColor = System.Drawing.SystemColors.Window; + dataGridViewCellStyle1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); + dataGridViewCellStyle1.ForeColor = System.Drawing.SystemColors.ControlText; + dataGridViewCellStyle1.SelectionBackColor = System.Drawing.SystemColors.Highlight; + dataGridViewCellStyle1.SelectionForeColor = System.Drawing.SystemColors.HighlightText; + dataGridViewCellStyle1.WrapMode = System.Windows.Forms.DataGridViewTriState.True; + this.gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle1; this.gridEntryDataGridView.Dock = System.Windows.Forms.DockStyle.Fill; this.gridEntryDataGridView.Location = new System.Drawing.Point(0, 0); this.gridEntryDataGridView.Name = "gridEntryDataGridView"; - this.gridEntryDataGridView.ReadOnly = true; this.gridEntryDataGridView.RowHeadersVisible = false; this.gridEntryDataGridView.RowTemplate.Height = 82; - this.gridEntryDataGridView.Size = new System.Drawing.Size(1510, 380); + 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.CellToolTipTextNeeded += new System.Windows.Forms.DataGridViewCellToolTipTextNeededEventHandler(this.gridEntryDataGridView_CellToolTipTextNeeded); // + // removeGVColumn + // + this.removeGVColumn.DataPropertyName = "Remove"; + this.removeGVColumn.FalseValue = ""; + this.removeGVColumn.Frozen = true; + this.removeGVColumn.HeaderText = "Remove"; + this.removeGVColumn.IndeterminateValue = ""; + this.removeGVColumn.MinimumWidth = 60; + this.removeGVColumn.Name = "removeGVColumn"; + this.removeGVColumn.Resizable = System.Windows.Forms.DataGridViewTriState.False; + this.removeGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + this.removeGVColumn.ThreeState = true; + this.removeGVColumn.TrueValue = ""; + this.removeGVColumn.Width = 60; + // // liberateGVColumn // this.liberateGVColumn.DataPropertyName = "Liberate"; @@ -223,7 +239,7 @@ this.AutoScroll = true; this.Controls.Add(this.gridEntryDataGridView); this.Name = "ProductsGrid"; - this.Size = new System.Drawing.Size(1510, 380); + this.Size = new System.Drawing.Size(1570, 380); this.Load += new System.EventHandler(this.ProductsGrid_Load); ((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.syncBindingSource)).EndInit(); @@ -235,6 +251,8 @@ #endregion private System.Windows.Forms.DataGridView gridEntryDataGridView; private System.Windows.Forms.ContextMenuStrip contextMenuStrip1; + private SyncBindingSource syncBindingSource; + private System.Windows.Forms.DataGridViewCheckBoxColumn removeGVColumn; private LiberateDataGridViewImageButtonColumn liberateGVColumn; private System.Windows.Forms.DataGridViewImageColumn coverGVColumn; private System.Windows.Forms.DataGridViewTextBoxColumn titleGVColumn; @@ -249,6 +267,5 @@ private System.Windows.Forms.DataGridViewTextBoxColumn myRatingGVColumn; private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn; private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn; - private SyncBindingSource syncBindingSource; } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index c13a2d12..578a83b8 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -23,12 +23,15 @@ namespace LibationWinForms.GridView public event LibraryBookEntryClickedEventHandler DetailsClicked; public event GridEntryRectangleClickedEventHandler DescriptionClicked; public new event EventHandler Scroll; + public event EventHandler RemovableCountChanged; private GridEntryBindingList bindingList; internal IEnumerable GetVisibleBooks() => bindingList .BookEntries() .Select(lbe => lbe.LibraryBook); + internal IEnumerable GetAllBookEntries() + => bindingList.AllItems().BookEntries(); public ProductsGrid() { @@ -81,6 +84,12 @@ namespace LibationWinForms.GridView else if (e.ColumnIndex == coverGVColumn.Index) CoverClicked?.Invoke(sEntry); } + + if (e.ColumnIndex == removeGVColumn.Index) + { + gridEntryDataGridView.CommitEdit(DataGridViewDataErrorContexts.Commit); + RemovableCountChanged?.Invoke(this, EventArgs.Empty); + } } private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem(rowIndex); @@ -89,13 +98,33 @@ namespace LibationWinForms.GridView #region UI display functions + internal bool RemoveColumnVisible + { + get => removeGVColumn.Visible; + set + { + if (value) + { + foreach (var book in bindingList.AllItems()) + book.Remove = RemoveStatus.NotRemoved; + } + removeGVColumn.Visible = value; + } + } + internal void BindToGrid(List dbBooks) { - var geList = dbBooks.Where(lb => lb.Book.IsProduct()).Select(b => new LibraryBookEntry(b)).Cast().ToList(); + var geList = dbBooks + .Where(lb => lb.Book.IsProduct()) + .Select(b => new LibraryBookEntry(b)) + .Cast() + .ToList(); var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()); - - foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent())) + + var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList(); + + foreach (var parent in seriesBooks) { var seriesEpisodes = episodes.FindChildren(parent); @@ -127,6 +156,7 @@ namespace LibationWinForms.GridView var allEntries = bindingList.AllItems().BookEntries(); var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); + var parentedEpisodes = dbBooks.ParentedEpisodes(); foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) { @@ -134,7 +164,8 @@ namespace LibationWinForms.GridView if (libraryBook.Book.IsProduct()) AddOrUpdateBook(libraryBook, existingEntry); - else if(libraryBook.Book.IsEpisodeChild()) + else if(parentedEpisodes.Any(lb => lb == libraryBook)) + //Only try to add or update is this LibraryBook is a know child of a parent AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); } @@ -153,6 +184,11 @@ namespace LibationWinForms.GridView .BookEntries() .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); + RemoveBooks(removedBooks); + } + + public void RemoveBooks(IEnumerable removedBooks) + { //Remove books in series from their parents' Children list foreach (var removed in removedBooks.Where(b => b.Parent is not null)) { @@ -188,6 +224,7 @@ namespace LibationWinForms.GridView if (existingEpisodeEntry is null) { LibraryBookEntry episodeEntry; + var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); if (seriesEntry is null) @@ -197,13 +234,14 @@ namespace LibationWinForms.GridView if (seriesBook is null) { - //This should be impossible because the importer ensures every episode has a parent. - var ex = new ApplicationException($"Episode's series parent not found in database."); - var seriesLinks = string.Join("\r\n", episodeBook.Book.SeriesLink?.Select(sb => $"{nameof(sb.Series.Name)}={sb.Series.Name}, {nameof(sb.Series.AudibleSeriesId)}={sb.Series.AudibleSeriesId}")); - Serilog.Log.Logger.Error(ex, "Episode={episodeBook}, Series: {seriesLinks}", episodeBook, seriesLinks); - throw ex; + //This is only possible if the user's db has some malformed + //entries from earlier Libation releases that could not be + //automatically fixed. Log, but don't throw. + Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames()); + return; } + seriesEntry = new SeriesEntry(seriesBook, episodeBook); seriesEntries.Add(seriesEntry); @@ -312,6 +350,14 @@ namespace LibationWinForms.GridView column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index); } + + //Remove column is always first; + removeGVColumn.DisplayIndex = 0; + removeGVColumn.Visible = false; + removeGVColumn.ValueType = typeof(RemoveStatus); + removeGVColumn.FalseValue = RemoveStatus.NotRemoved; + removeGVColumn.TrueValue = RemoveStatus.Removed; + removeGVColumn.IndeterminateValue = RemoveStatus.SomeRemoved; } private void HideMenuItem_Click(object sender, EventArgs e) diff --git a/Source/LibationWinForms/GridView/ProductsGrid.resx b/Source/LibationWinForms/GridView/ProductsGrid.resx index bc15cd01..72deb6eb 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.resx +++ b/Source/LibationWinForms/GridView/ProductsGrid.resx @@ -57,16 +57,13 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + True + 171, 17 17, 17 - - 326, 17 - - - 326, 17 - \ No newline at end of file diff --git a/Source/LibationWinForms/GridView/QueryExtensions.cs b/Source/LibationWinForms/GridView/QueryExtensions.cs index d084aec7..1fb52bdf 100644 --- a/Source/LibationWinForms/GridView/QueryExtensions.cs +++ b/Source/LibationWinForms/GridView/QueryExtensions.cs @@ -24,12 +24,20 @@ namespace LibationWinForms.GridView { if (seriesEpisode.Book.SeriesLink is null) return null; - //Parent books will always have exactly 1 SeriesBook due to how - //they are imported in ApiExtended.getChildEpisodesAsync() - return gridEntries.SeriesEntries().FirstOrDefault( - lb => - seriesEpisode.Book.SeriesLink.Any( - s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId)); + try + { + //Parent books will always have exactly 1 SeriesBook due to how + //they are imported in ApiExtended.getChildEpisodesAsync() + return gridEntries.SeriesEntries().FirstOrDefault( + lb => + seriesEpisode.Book.SeriesLink.Any( + s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId)); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent)); + return null; + } } } #nullable disable diff --git a/Source/LibationWinForms/GridView/SeriesEntry.cs b/Source/LibationWinForms/GridView/SeriesEntry.cs index ca422029..9ceba47e 100644 --- a/Source/LibationWinForms/GridView/SeriesEntry.cs +++ b/Source/LibationWinForms/GridView/SeriesEntry.cs @@ -13,7 +13,43 @@ namespace LibationWinForms.GridView [Browsable(false)] public List Children { get; } [Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded); + private bool suspendCounting = false; + public void ChildRemoveUpdate() + { + if (suspendCounting) return; + + var removeCount = Children.Count(c => c.Remove is RemoveStatus.Removed); + + if (removeCount == 0) + _remove = RemoveStatus.NotRemoved; + else if (removeCount == Children.Count) + _remove = RemoveStatus.Removed; + else + _remove = RemoveStatus.SomeRemoved; + NotifyPropertyChanged(nameof(Remove)); + } + #region Model properties exposed to the view + public override RemoveStatus Remove + { + get + { + return _remove; + } + set + { + _remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value; + + suspendCounting = true; + + foreach (var item in Children) + item.Remove = value; + + suspendCounting = false; + + NotifyPropertyChanged(); + } + } public override LiberateButtonStatus Liberate { get; } public override string DisplayTags { get; } = string.Empty; diff --git a/Source/LibationWinForms/Properties/Resources.Designer.cs b/Source/LibationWinForms/Properties/Resources.Designer.cs index f3e881e2..280a5d69 100644 --- a/Source/LibationWinForms/Properties/Resources.Designer.cs +++ b/Source/LibationWinForms/Properties/Resources.Designer.cs @@ -130,6 +130,16 @@ namespace LibationWinForms.Properties { } } + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap error { + get { + object obj = ResourceManager.GetObject("error", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + /// /// Looks up a localized resource of type System.Drawing.Bitmap. /// diff --git a/Source/LibationWinForms/Properties/Resources.resx b/Source/LibationWinForms/Properties/Resources.resx index 95b3b8a0..a78be5b3 100644 --- a/Source/LibationWinForms/Properties/Resources.resx +++ b/Source/LibationWinForms/Properties/Resources.resx @@ -139,6 +139,9 @@ ..\Resources\edit-tags-50x50.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + ..\Resources\error.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + ..\Resources\import_16x16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a diff --git a/Source/LibationWinForms/Resources/Stoplight1 with pdf.psd b/Source/LibationWinForms/Resources/Stoplight1 with pdf.psd new file mode 100644 index 00000000..80542c2c Binary files /dev/null and b/Source/LibationWinForms/Resources/Stoplight1 with pdf.psd differ diff --git a/Source/LibationWinForms/Resources/Stoplight1.psd b/Source/LibationWinForms/Resources/Stoplight1.psd new file mode 100644 index 00000000..9b5f8105 Binary files /dev/null and b/Source/LibationWinForms/Resources/Stoplight1.psd differ diff --git a/Source/LibationWinForms/Resources/error.png b/Source/LibationWinForms/Resources/error.png new file mode 100644 index 00000000..700ce41e Binary files /dev/null and b/Source/LibationWinForms/Resources/error.png differ diff --git a/Source/LibationWinForms/Resources/liberate_green.png b/Source/LibationWinForms/Resources/liberate_green.png index b552ac62..86171e0c 100644 Binary files a/Source/LibationWinForms/Resources/liberate_green.png and b/Source/LibationWinForms/Resources/liberate_green.png differ diff --git a/Source/LibationWinForms/Resources/liberate_green_pdf_no.png b/Source/LibationWinForms/Resources/liberate_green_pdf_no.png index bbdca3e4..a128c088 100644 Binary files a/Source/LibationWinForms/Resources/liberate_green_pdf_no.png and b/Source/LibationWinForms/Resources/liberate_green_pdf_no.png differ diff --git a/Source/LibationWinForms/Resources/liberate_green_pdf_yes.png b/Source/LibationWinForms/Resources/liberate_green_pdf_yes.png index 46408927..baac0151 100644 Binary files a/Source/LibationWinForms/Resources/liberate_green_pdf_yes.png and b/Source/LibationWinForms/Resources/liberate_green_pdf_yes.png differ diff --git a/Source/LibationWinForms/Resources/liberate_red.png b/Source/LibationWinForms/Resources/liberate_red.png index 8e74022c..8e4b34e4 100644 Binary files a/Source/LibationWinForms/Resources/liberate_red.png and b/Source/LibationWinForms/Resources/liberate_red.png differ diff --git a/Source/LibationWinForms/Resources/liberate_red_pdf_no.png b/Source/LibationWinForms/Resources/liberate_red_pdf_no.png index 26d019d9..6506603c 100644 Binary files a/Source/LibationWinForms/Resources/liberate_red_pdf_no.png and b/Source/LibationWinForms/Resources/liberate_red_pdf_no.png differ diff --git a/Source/LibationWinForms/Resources/liberate_red_pdf_yes.png b/Source/LibationWinForms/Resources/liberate_red_pdf_yes.png index f1183c5c..0d5b5eb6 100644 Binary files a/Source/LibationWinForms/Resources/liberate_red_pdf_yes.png and b/Source/LibationWinForms/Resources/liberate_red_pdf_yes.png differ diff --git a/Source/LibationWinForms/Resources/liberate_yellow.png b/Source/LibationWinForms/Resources/liberate_yellow.png index d2ea7791..8b3e8aab 100644 Binary files a/Source/LibationWinForms/Resources/liberate_yellow.png and b/Source/LibationWinForms/Resources/liberate_yellow.png differ diff --git a/Source/LibationWinForms/Resources/liberate_yellow_pdf_no.png b/Source/LibationWinForms/Resources/liberate_yellow_pdf_no.png index 1e917dea..2bddcffd 100644 Binary files a/Source/LibationWinForms/Resources/liberate_yellow_pdf_no.png and b/Source/LibationWinForms/Resources/liberate_yellow_pdf_no.png differ diff --git a/Source/LibationWinForms/Resources/liberate_yellow_pdf_yes.png b/Source/LibationWinForms/Resources/liberate_yellow_pdf_yes.png index bc19f298..b51a28ad 100644 Binary files a/Source/LibationWinForms/Resources/liberate_yellow_pdf_yes.png and b/Source/LibationWinForms/Resources/liberate_yellow_pdf_yes.png differ diff --git a/Source/LibationWinForms/Resources/minus.png b/Source/LibationWinForms/Resources/minus.png index bfc2cf4a..c0c5d15c 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 1cae3d6b..1cd1c630 100644 Binary files a/Source/LibationWinForms/Resources/plus.png and b/Source/LibationWinForms/Resources/plus.png differ