From 0faeeea25f2af5dcf9c83844c0d578a3b9c3ae12 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Tue, 10 May 2022 14:50:34 -0400 Subject: [PATCH 1/5] update dependencies --- Source/DataLayer/DataLayer.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/DataLayer/DataLayer.csproj b/Source/DataLayer/DataLayer.csproj index 64a4f93d..51bbd9b1 100644 --- a/Source/DataLayer/DataLayer.csproj +++ b/Source/DataLayer/DataLayer.csproj @@ -12,13 +12,13 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive From 7af890d89764601c3f3fe072dd7b24512a46bd2d Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Tue, 10 May 2022 15:32:00 -0400 Subject: [PATCH 2/5] GetLibrary to include image sizes 500, 1215 --- Source/ApplicationServices/LibraryCommands.cs | 31 ++++++++++++------- Source/AudibleUtilities/ApiExtended.cs | 8 ++--- .../AudibleUtilities/AudibleUtilities.csproj | 2 +- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 3bbf93c5..3ceab15b 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -33,18 +33,20 @@ namespace ApplicationServices //These are the minimum response groups required for the //library scanner to pass all validation and filtering. - var libraryResponseGroups = - LibraryOptions.ResponseGroupOptions.ProductAttrs | - LibraryOptions.ResponseGroupOptions.ProductDesc | - LibraryOptions.ResponseGroupOptions.Relationships; - - if (accounts is null || accounts.Length == 0) + var libraryOptions = new LibraryOptions + { + ResponseGroups + = LibraryOptions.ResponseGroupOptions.ProductAttrs + | LibraryOptions.ResponseGroupOptions.ProductDesc + | LibraryOptions.ResponseGroupOptions.Relationships + }; + if (accounts is null || accounts.Length == 0) return new List(); try { logTime($"pre {nameof(scanAccountsAsync)} all"); - var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryResponseGroups); + var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions); logTime($"post {nameof(scanAccountsAsync)} all"); var totalCount = libraryItems.Count; @@ -102,7 +104,12 @@ namespace ApplicationServices } logTime($"pre {nameof(scanAccountsAsync)} all"); - var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, LibraryOptions.ResponseGroupOptions.ALL_OPTIONS); + var libraryOptions = new LibraryOptions + { + ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS, + ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215 + }; + var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions); logTime($"post {nameof(scanAccountsAsync)} all"); var totalCount = importItems.Count; @@ -150,7 +157,7 @@ namespace ApplicationServices } } - private static async Task> scanAccountsAsync(Func> apiExtendedfunc, Account[] accounts, LibraryOptions.ResponseGroupOptions libraryResponseGroups) + private static async Task> scanAccountsAsync(Func> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions) { var tasks = new List>>(); foreach (var account in accounts) @@ -159,7 +166,7 @@ namespace ApplicationServices var apiExtended = await apiExtendedfunc(account); // add scanAccountAsync as a TASK: do not await - tasks.Add(scanAccountAsync(apiExtended, account, libraryResponseGroups)); + tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions)); } // import library in parallel @@ -168,7 +175,7 @@ namespace ApplicationServices return importItems; } - private static async Task> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions.ResponseGroupOptions libraryResponseGroups) + private static async Task> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions) { ArgumentValidator.EnsureNotNull(account, nameof(account)); @@ -179,7 +186,7 @@ namespace ApplicationServices logTime($"pre scanAccountAsync {account.AccountName}"); - var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryResponseGroups, Configuration.Instance.ImportEpisodes); + var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes); logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}"); diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 512949ef..5a00c9a7 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -106,16 +106,16 @@ namespace AudibleUtilities // 2 retries == 3 total .RetryAsync(2); - public Task> GetLibraryValidatedAsync(LibraryOptions.ResponseGroupOptions responseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS, bool importEpisodes = true) + public Task> GetLibraryValidatedAsync(LibraryOptions libraryOptions, bool importEpisodes = true) { // bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or not tokens are refreshed // worse, this 1st dummy call doesn't seem to help: // var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS }); // i don't want to incur the cost of making a full dummy call every time because it fails sometimes - return policy.ExecuteAsync(() => getItemsAsync(responseGroups, importEpisodes)); + return policy.ExecuteAsync(() => getItemsAsync(libraryOptions, importEpisodes)); } - private async Task> getItemsAsync(LibraryOptions.ResponseGroupOptions responseGroups, bool importEpisodes) + private async Task> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes) { var items = new List(); #if DEBUG @@ -131,7 +131,7 @@ namespace AudibleUtilities Serilog.Log.Logger.Debug("Begin initial library scan"); if (!items.Any()) - items = await Api.GetAllLibraryItemsAsync(responseGroups); + items = await Api.GetAllLibraryItemsAsync(libraryOptions); Serilog.Log.Logger.Debug("Initial library scan complete. Begin episode scan"); diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index 1b933a47..b96dcfe2 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -5,7 +5,7 @@ - + From dd5e162c10c44d23da7b3e2743eb5fbd1622cd12 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Tue, 10 May 2022 16:17:12 -0400 Subject: [PATCH 3/5] db persistence shouldn't be a side effect. the client should say when to persist in some fairly explicit way --- Source/LibationWinForms/grid/GridEntry.cs | 62 +++++++------------- Source/LibationWinForms/grid/ProductsGrid.cs | 24 ++++---- 2 files changed, 32 insertions(+), 54 deletions(-) diff --git a/Source/LibationWinForms/grid/GridEntry.cs b/Source/LibationWinForms/grid/GridEntry.cs index ce9d552d..7fabcb38 100644 --- a/Source/LibationWinForms/grid/GridEntry.cs +++ b/Source/LibationWinForms/grid/GridEntry.cs @@ -58,11 +58,7 @@ namespace LibationWinForms public string Category { get; private set; } public string Misc { get; private set; } public string Description { get; private set; } - public string DisplayTags - { - get => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated); - set => Book.UserDefinedItem.Tags = value; - } + public string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated); // these 2 values being in 1 field is the trick behind getting the liberated+pdf 'stoplight' icon to draw. See: LiberateDataGridViewImageButtonCell.Paint public (LiberatedStatus BookStatus, LiberatedStatus? PdfStatus) Liberate @@ -77,18 +73,10 @@ namespace LibationWinForms } return (_bookStatus, _pdfStatus); } - - set - { - _bookStatus = value.BookStatus; - _pdfStatus = value.PdfStatus; - LibraryBook.Book.UserDefinedItem.BookStatus = value.BookStatus; - LibraryBook.Book.UserDefinedItem.PdfStatus = value.PdfStatus; - } } #endregion - public event EventHandler LibraryBookUpdated; + public event EventHandler LibraryBookUpdated; public event EventHandler Committed; // alias @@ -156,7 +144,7 @@ namespace LibationWinForms UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; // this will never have a value when triggered by ctor b/c nothing can subscribe to the event until after ctor is complete - LibraryBookUpdated?.Invoke(this, AudibleProductId); + LibraryBookUpdated?.Invoke(this, null); } private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) @@ -196,38 +184,30 @@ namespace LibationWinForms NotifyPropertyChanged(nameof(Liberate)); break; } - - if (!suspendCommit) - Commit(); - } - private bool suspendCommit = false; - - /// - /// Begin editing the model, suspending commits until is called. - /// - public void BeginEdit() => suspendCommit = true; - - /// - /// Save all edits to the database. - /// - public void EndEdit() - { - Commit(); - suspendCommit = false; } - private void Commit() + /// Save edits to the database + public void Commit(string newTags, LiberatedStatus bookStatus, LiberatedStatus? pdfStatus) { - // We don't want LiberatedStatus.PartialDownload to be a persistent status. - // If display/icon status is PartialDownload then save NotLiberated to db then restore PartialDownload for display - var displayStatus = Book.UserDefinedItem.BookStatus; - var saveStatus = displayStatus == LiberatedStatus.PartialDownload ? LiberatedStatus.NotLiberated : displayStatus; - Book.UserDefinedItem.BookStatus = saveStatus; + // validate + if (DisplayTags.EqualsInsensitive(newTags) && + Liberate.BookStatus == bookStatus && + Liberate.PdfStatus == pdfStatus) + return; + // set + Book.UserDefinedItem.Tags = newTags; + + _bookStatus = bookStatus; + _pdfStatus = pdfStatus; + + Book.UserDefinedItem.BookStatus = bookStatus; + Book.UserDefinedItem.PdfStatus = pdfStatus; + + // save LibraryCommands.UpdateUserDefinedItem(Book); - Book.UserDefinedItem.BookStatus = displayStatus; - + // notify Committed?.Invoke(this, null); } diff --git a/Source/LibationWinForms/grid/ProductsGrid.cs b/Source/LibationWinForms/grid/ProductsGrid.cs index 239288b6..71938d70 100644 --- a/Source/LibationWinForms/grid/ProductsGrid.cs +++ b/Source/LibationWinForms/grid/ProductsGrid.cs @@ -114,15 +114,8 @@ namespace LibationWinForms private static void Details_Click(GridEntry liveGridEntry) { var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook); - if (bookDetailsForm.ShowDialog() != DialogResult.OK) - return; - - liveGridEntry.BeginEdit(); - - liveGridEntry.DisplayTags = bookDetailsForm.NewTags; - liveGridEntry.Liberate = (bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); - - liveGridEntry.EndEdit(); + if (bookDetailsForm.ShowDialog() == DialogResult.OK) + liveGridEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); } #endregion @@ -203,7 +196,7 @@ namespace LibationWinForms { var entry = new GridEntry(libraryBook); entry.Committed += Filter; - entry.LibraryBookUpdated += (sender, productId) => _dataGridView.InvalidateRow(_dataGridView.GetRowIdOfBoundItem((GridEntry)sender)); + entry.LibraryBookUpdated += (sender, _) => _dataGridView.InvalidateRow(_dataGridView.GetRowIdOfBoundItem((GridEntry)sender)); return entry; } @@ -236,14 +229,19 @@ namespace LibationWinForms // Causes repainting of the DataGridView bindingContext.ResumeBinding(); - VisibleCountChanged?.Invoke(this, _dataGridView.AsEnumerable().Count(r => r.Visible)); + VisibleCountChanged?.Invoke(this, GetVisible().Count()); } #endregion - #region DataGridView Macro + internal IEnumerable GetVisible() + => _dataGridView + .AsEnumerable() + .Where(row => row.Visible) + .Select(row => ((GridEntry)row.DataBoundItem).LibraryBook) + .ToList(); + private GridEntry getGridEntry(int rowIndex) => _dataGridView.GetBoundItem(rowIndex); - #endregion #region Column Customizations From c6ce814e1c10dbe2d19b802645e4eb4fefec4b08 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Tue, 10 May 2022 16:18:09 -0400 Subject: [PATCH 4/5] Set the stage for batch processing --- Source/ApplicationServices/LibraryCommands.cs | 33 ++++++++++++++----- Source/DataLayer/EfClasses/UserDefinedItem.cs | 8 +++-- .../ProcessorAutomationController.cs | 15 ++++++--- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 3ceab15b..027ae536 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -266,30 +266,47 @@ namespace ApplicationServices /// Occurs when , , or /// changed values are successfully persisted. /// - public static event EventHandler BookUserDefinedItemCommitted; + public static event EventHandler BookUserDefinedItemCommitted; #region Update book details - public static int UpdateUserDefinedItem(Book book) + public static int UpdateUserDefinedItem(params Book[] books) => UpdateUserDefinedItem(books.ToList()); + public static int UpdateUserDefinedItem(IEnumerable books) { try { + if (books is null || !books.Any()) + return 0; + using var context = DbContexts.GetContext(); // Attach() NoTracking entities before SaveChanges() - context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified; + foreach (var book in books) + context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified; + var qtyChanges = context.SaveChanges(); - if (qtyChanges > 0) + if (qtyChanges == 0) + return 0; + + // semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs. + // I did not benchmark before choosing the number here + if (qtyChanges > 15) + SearchEngineCommands.FullReIndex(); + else { - SearchEngineCommands.UpdateLiberatedStatus(book); - SearchEngineCommands.UpdateBookTags(book); - BookUserDefinedItemCommitted?.Invoke(null, book.AudibleProductId); + foreach (var book in books) + { + SearchEngineCommands.UpdateLiberatedStatus(book); + SearchEngineCommands.UpdateBookTags(book); + } } + BookUserDefinedItemCommitted?.Invoke(null, null); + return qtyChanges; } catch (Exception ex) { - Log.Logger.Error(ex, $"Error updating {nameof(book.UserDefinedItem)}"); + Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}"); throw; } } diff --git a/Source/DataLayer/EfClasses/UserDefinedItem.cs b/Source/DataLayer/EfClasses/UserDefinedItem.cs index f5da2cf8..28267a31 100644 --- a/Source/DataLayer/EfClasses/UserDefinedItem.cs +++ b/Source/DataLayer/EfClasses/UserDefinedItem.cs @@ -141,9 +141,11 @@ namespace DataLayer get => _bookStatus; set { - if (_bookStatus != value) - { - _bookStatus = value; + // PartialDownload is a live/ephemeral status, not a persistent one. Do not store + var displayStatus = value == LiberatedStatus.PartialDownload ? LiberatedStatus.NotLiberated : value; + if (_bookStatus != displayStatus) + { + _bookStatus = displayStatus; OnItemChanged(nameof(BookStatus)); } } diff --git a/Source/LibationWinForms/BookLiberation/ProcessorAutomationController.cs b/Source/LibationWinForms/BookLiberation/ProcessorAutomationController.cs index dca83b84..2433ac39 100644 --- a/Source/LibationWinForms/BookLiberation/ProcessorAutomationController.cs +++ b/Source/LibationWinForms/BookLiberation/ProcessorAutomationController.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; using DataLayer; @@ -61,7 +63,7 @@ namespace LibationWinForms.BookLiberation await new BackupSingle(logMe, backupBook, libraryBook).RunBackupAsync(); } - public static async Task BackupAllBooksAsync() + public static async Task BackupAllBooksAsync(List libraryBooks = null) { Serilog.Log.Logger.Information("Begin " + nameof(BackupAllBooksAsync)); @@ -69,7 +71,7 @@ namespace LibationWinForms.BookLiberation var logMe = LogMe.RegisterForm(automatedBackupsForm); var backupBook = CreateBackupBook(logMe); - await new BackupLoop(logMe, backupBook, automatedBackupsForm).RunBackupAsync(); + await new BackupLoop(logMe, backupBook, automatedBackupsForm, libraryBooks).RunBackupAsync(); } public static async Task ConvertAllBooksAsync() @@ -307,13 +309,16 @@ An error occurred while trying to process this book. protected override MessageBoxDefaultButton SkipDialogDefaultButton => MessageBoxDefaultButton.Button1; protected override DialogResult SkipResult => DialogResult.Ignore; - public BackupLoop(LogMe logMe, Processable processable, AutomatedBackupsForm automatedBackupsForm) - : base(logMe, processable, automatedBackupsForm) { } + private List libraryBooks { get; } + + public BackupLoop(LogMe logMe, Processable processable, AutomatedBackupsForm automatedBackupsForm, List libraryBooks = null) + : base(logMe, processable, automatedBackupsForm) + => this.libraryBooks = libraryBooks ?? ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking(); protected override async Task RunAsync() { // support for 'skip this time only' requires state. iterators provide this state for free. therefore: use foreach/iterator here - foreach (var libraryBook in Processable.GetValidLibraryBooks(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking())) + foreach (var libraryBook in Processable.GetValidLibraryBooks(libraryBooks)) { var keepGoing = await ProcessOneAsync(libraryBook, validate: false); if (!keepGoing) From e368e4669bf05bce7123299c5f2bfd8fd5740b7b Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Tue, 10 May 2022 16:48:52 -0400 Subject: [PATCH 5/5] bug fix: db persistence shouldn't be a side effect. the client should say when to persist in some fairly explicit way --- Source/AppScaffolding/AppScaffolding.csproj | 2 +- Source/FileLiberator/DownloadDecryptBook.cs | 1 + Source/FileLiberator/DownloadPdf.cs | 1 + Source/FileLiberator/FileLiberator.csproj | 1 + .../ProcessorAutomationController.cs | 2 ++ Source/LibationWinForms/grid/GridEntry.cs | 30 +++++++++---------- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/Source/AppScaffolding/AppScaffolding.csproj b/Source/AppScaffolding/AppScaffolding.csproj index aca3a938..c4ee9183 100644 --- a/Source/AppScaffolding/AppScaffolding.csproj +++ b/Source/AppScaffolding/AppScaffolding.csproj @@ -3,7 +3,7 @@ net6.0-windows - 7.3.0.1 + 7.4.0.1 diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index b03334a0..191979cf 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -76,6 +76,7 @@ namespace FileLiberator return new StatusHandler { "Cannot find final audio file after decryption" }; libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated; + ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book); return new StatusHandler(); } diff --git a/Source/FileLiberator/DownloadPdf.cs b/Source/FileLiberator/DownloadPdf.cs index a2378e31..bd4817e2 100644 --- a/Source/FileLiberator/DownloadPdf.cs +++ b/Source/FileLiberator/DownloadPdf.cs @@ -29,6 +29,7 @@ namespace FileLiberator var result = verifyDownload(actualDownloadedFilePath); libraryBook.Book.UserDefinedItem.PdfStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated; + ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book); return result; } diff --git a/Source/FileLiberator/FileLiberator.csproj b/Source/FileLiberator/FileLiberator.csproj index 3da05437..460308d6 100644 --- a/Source/FileLiberator/FileLiberator.csproj +++ b/Source/FileLiberator/FileLiberator.csproj @@ -6,6 +6,7 @@ + diff --git a/Source/LibationWinForms/BookLiberation/ProcessorAutomationController.cs b/Source/LibationWinForms/BookLiberation/ProcessorAutomationController.cs index 2433ac39..eb6ac3af 100644 --- a/Source/LibationWinForms/BookLiberation/ProcessorAutomationController.cs +++ b/Source/LibationWinForms/BookLiberation/ProcessorAutomationController.cs @@ -257,6 +257,8 @@ $@" Title: {libraryBook.Book.Title} if (dialogResult == SkipResult) { libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Error; + ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book); + LogMe.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}"); } diff --git a/Source/LibationWinForms/grid/GridEntry.cs b/Source/LibationWinForms/grid/GridEntry.cs index 7fabcb38..aee51d4c 100644 --- a/Source/LibationWinForms/grid/GridEntry.cs +++ b/Source/LibationWinForms/grid/GridEntry.cs @@ -86,18 +86,18 @@ namespace LibationWinForms public async Task DownloadBook() { - if (!DownloadInProgress) + if (DownloadInProgress) + return; + + try { - try - { - DownloadInProgress = true; - await BookLiberation.ProcessorAutomationController.BackupSingleBookAsync(LibraryBook); - UpdateLiberatedStatus(); - } - finally - { - DownloadInProgress = false; - } + DownloadInProgress = true; + await BookLiberation.ProcessorAutomationController.BackupSingleBookAsync(LibraryBook); + UpdateLiberatedStatus(); + } + finally + { + DownloadInProgress = false; } } @@ -195,16 +195,14 @@ namespace LibationWinForms Liberate.PdfStatus == pdfStatus) return; - // set - Book.UserDefinedItem.Tags = newTags; - + // update cache _bookStatus = bookStatus; _pdfStatus = pdfStatus; + // set + save + Book.UserDefinedItem.Tags = newTags; Book.UserDefinedItem.BookStatus = bookStatus; Book.UserDefinedItem.PdfStatus = pdfStatus; - - // save LibraryCommands.UpdateUserDefinedItem(Book); // notify