diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 8de17cb8..f93fb1ef 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -73,7 +73,8 @@ namespace DataLayer ContentType contentType, IEnumerable authors, IEnumerable narrators, - string localeName) + string localeName + ) { // validate ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId)); diff --git a/Source/DataLayer/EfClasses/LibraryBook.cs b/Source/DataLayer/EfClasses/LibraryBook.cs index 32ef263d..9c57fe0a 100644 --- a/Source/DataLayer/EfClasses/LibraryBook.cs +++ b/Source/DataLayer/EfClasses/LibraryBook.cs @@ -13,7 +13,8 @@ namespace DataLayer public bool IsDeleted { get; set; } public bool AbsentFromLastScan { get; set; } - + + public DateTime? IncludedUntil { get; private set; } private LibraryBook() { } public LibraryBook(Book book, DateTime dateAdded, string account) { @@ -26,7 +27,7 @@ namespace DataLayer } public void SetAccount(string account) => Account = account; - - public override string ToString() => $"{DateAdded:d} {Book}"; - } -} + public void SetIncludedUntil(DateTime? includedUntil) => IncludedUntil = includedUntil; + public override string ToString() => $"{DateAdded:d} {Book}"; + } +} \ No newline at end of file diff --git a/Source/DataLayer/Migrations/20251020175053_AddIncludedUntil.Designer.cs b/Source/DataLayer/Migrations/20251020175053_AddIncludedUntil.Designer.cs new file mode 100644 index 00000000..104a17f0 --- /dev/null +++ b/Source/DataLayer/Migrations/20251020175053_AddIncludedUntil.Designer.cs @@ -0,0 +1,477 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20251020175053_AddIncludedUntil")] + partial class AddIncludedUntil + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.Property("_categoriesCategoryId") + .HasColumnType("INTEGER"); + + b.Property("_categoryLaddersCategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId"); + + b.HasIndex("_categoryLaddersCategoryLadderId"); + + b.ToTable("CategoryCategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property("ContentType") + .HasColumnType("INTEGER"); + + b.Property("DatePublished") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAbridged") + .HasColumnType("INTEGER"); + + b.Property("IsSpatial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LengthInMinutes") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasColumnType("TEXT"); + + b.Property("PictureId") + .HasColumnType("TEXT"); + + b.Property("PictureLarge") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("CategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "CategoryLadderId"); + + b.HasIndex("BookId"); + + b.HasIndex("CategoryLadderId"); + + b.ToTable("BookCategory"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("ContributorId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleCategoryId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Property("CategoryLadderId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("CategoryLadderId"); + + b.ToTable("CategoryLadders"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleContributorId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("AbsentFromLastScan") + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("IncludedUntil") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.HasKey("BookId"); + + b.ToTable("LibraryBooks"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleSeriesId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.HasOne("DataLayer.Category", null) + .WithMany() + .HasForeignKey("_categoriesCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", null) + .WithMany() + .HasForeignKey("_categoryLaddersCategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("OverallRating") + .HasColumnType("REAL"); + + b1.Property("PerformanceRating") + .HasColumnType("REAL"); + + b1.Property("StoryRating") + .HasColumnType("REAL"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.WithOwner() + .HasForeignKey("BookId"); + }); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("Url") + .HasColumnType("TEXT"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.Navigation("Book"); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("BookStatus") + .HasColumnType("INTEGER"); + + b1.Property("IsFinished") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloaded") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFileVersion") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloadedVersion") + .HasColumnType("TEXT"); + + b1.Property("PdfStatus") + .HasColumnType("INTEGER"); + + b1.Property("Tags") + .HasColumnType("TEXT"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem", (string)null); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId") + .HasColumnType("INTEGER"); + + b2.Property("OverallRating") + .HasColumnType("REAL"); + + b2.Property("PerformanceRating") + .HasColumnType("REAL"); + + b2.Property("StoryRating") + .HasColumnType("REAL"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.WithOwner() + .HasForeignKey("UserDefinedItemBookId"); + }); + + b1.Navigation("Book"); + + b1.Navigation("Rating"); + }); + + b.Navigation("Rating"); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("CategoriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", "CategoryLadder") + .WithMany("BooksLink") + .HasForeignKey("CategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("CategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Contributor"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Navigation("CategoriesLink"); + + b.Navigation("ContributorsLink"); + + b.Navigation("SeriesLink"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Navigation("BooksLink"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Source/DataLayer/Migrations/20251020175053_AddIncludedUntil.cs b/Source/DataLayer/Migrations/20251020175053_AddIncludedUntil.cs new file mode 100644 index 00000000..2f31c7fc --- /dev/null +++ b/Source/DataLayer/Migrations/20251020175053_AddIncludedUntil.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// + public partial class AddIncludedUntil : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IncludedUntil", + table: "LibraryBooks", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IncludedUntil", + table: "LibraryBooks"); + } + } +} diff --git a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs index 99f70af0..65f867f9 100644 --- a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace DataLayer.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); modelBuilder.Entity("CategoryCategoryLadder", b => { @@ -194,6 +194,9 @@ namespace DataLayer.Migrations b.Property("DateAdded") .HasColumnType("TEXT"); + b.Property("IncludedUntil") + .HasColumnType("TEXT"); + b.Property("IsDeleted") .HasColumnType("INTEGER"); diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index e6596d09..e804ce34 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -111,7 +111,8 @@ namespace DtoImporterService contentType, authors, narrators, - importItem.LocaleName) + importItem.LocaleName + ) ).Entity; Cache.Add(book.AudibleProductId, book); } diff --git a/Source/DtoImporterService/LibraryBookImporter.cs b/Source/DtoImporterService/LibraryBookImporter.cs index a2dcfb45..689a1cc7 100644 --- a/Source/DtoImporterService/LibraryBookImporter.cs +++ b/Source/DtoImporterService/LibraryBookImporter.cs @@ -8,121 +8,136 @@ using Dinah.Core.Collections.Generic; namespace DtoImporterService { - public class LibraryBookImporter : ItemsImporterBase - { - protected override IValidator Validator => new LibraryValidator(); + public class LibraryBookImporter : ItemsImporterBase + { + protected override IValidator Validator => new LibraryValidator(); - private BookImporter bookImporter { get; } + private BookImporter bookImporter { get; } - public LibraryBookImporter(LibationContext context) : base(context) - { - bookImporter = new BookImporter(DbContext); - } + public LibraryBookImporter(LibationContext context) : base(context) + { + bookImporter = new BookImporter(DbContext); + } - protected override int DoImport(IEnumerable importItems) - { - bookImporter.Import(importItems); + protected override int DoImport(IEnumerable importItems) + { + bookImporter.Import(importItems); - var qtyNew = upsertLibraryBooks(importItems); - return qtyNew; - } + var qtyNew = upsertLibraryBooks(importItems); + return qtyNew; + } - private int upsertLibraryBooks(IEnumerable importItems) - { - // technically, we should be able to have duplicate books from separate accounts. - // this would violate the current pk and would be difficult to deal with elsewhere: - // - what to show in the grid - // - which to consider liberated - // - // sqlite cannot alter pk. the work around is an extensive headache - // - update: now possible in .net5/efcore5 - // - // currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region. - // - // CURRENT SOLUTION: don't re-insert + private int upsertLibraryBooks(IEnumerable importItems) + { + // technically, we should be able to have duplicate books from separate accounts. + // this would violate the current pk and would be difficult to deal with elsewhere: + // - what to show in the grid + // - which to consider liberated + // + // sqlite cannot alter pk. the work around is an extensive headache + // - update: now possible in .net5/efcore5 + // + // currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region. + // + // CURRENT SOLUTION: don't re-insert - //When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext - //instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter. - //There should never be duplicates, but this is defensive. - var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId); + //When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext + //instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter. + //There should never be duplicates, but this is defensive. + var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId); - //If importItems are contains duplicates by asin, keep the Item that's "available" - var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak); + //If importItems are contains duplicates by asin, keep the Item that's "available" + var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak); - int qtyNew = 0; + int qtyNew = 0; - foreach (var item in uniqueImportItems.Values) - { - if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing)) - { - if (existing.Account != item.AccountId) - { - //Book is absent from the existing LibraryBook's account. Use the alternate account. - existing.SetAccount(item.AccountId); - } + foreach (var item in uniqueImportItems.Values) + { + if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing)) + { + if (existing.Account != item.AccountId) + { + //Book is absent from the existing LibraryBook's account. Use the alternate account. + existing.SetAccount(item.AccountId); + } - existing.AbsentFromLastScan = isUnavailable(item); - } - else - { - var libraryBook = new LibraryBook( - bookImporter.Cache[item.DtoItem.ProductId], - item.DtoItem.DateAdded, - item.AccountId) - { - AbsentFromLastScan = isUnavailable(item) - }; + existing.AbsentFromLastScan = isUnavailable(item); + } + else + { + existing = new LibraryBook( + bookImporter.Cache[item.DtoItem.ProductId], + item.DtoItem.DateAdded, + item.AccountId) + { + AbsentFromLastScan = isUnavailable(item) + }; - try - { - DbContext.LibraryBooks.Add(libraryBook); - qtyNew++; - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account }); - } - } - } + try + { + DbContext.LibraryBooks.Add(existing); + qtyNew++; + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { existing.Book, existing.Account }); + } + } + + existing.SetIncludedUntil(GetExpirationDate(item)); + } - var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList(); + var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList(); - //If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null. - //Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned. - foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts))) - nullBook.AbsentFromLastScan = true; + //If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null. + //Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned. + foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts))) + nullBook.AbsentFromLastScan = true; - return qtyNew; - } + return qtyNew; + } - private static Dictionary ToDictionarySafe(IEnumerable source, Func keySelector, Func tieBreaker) - { - var dictionary = new Dictionary(); + private static Dictionary ToDictionarySafe(IEnumerable source, Func keySelector, Func tieBreaker) + { + var dictionary = new Dictionary(); - foreach (TSource newItem in source) - { - TKey key = keySelector(newItem); + foreach (TSource newItem in source) + { + TKey key = keySelector(newItem); - dictionary[key] - = dictionary.TryGetValue(key, out TSource existingItem) - ? tieBreaker(existingItem, newItem) - : newItem; - } - return dictionary; - } + dictionary[key] + = dictionary.TryGetValue(key, out TSource existingItem) + ? tieBreaker(existingItem, newItem) + : newItem; + } + return dictionary; + } - private static ImportItem tieBreak(ImportItem item1, ImportItem item2) - => isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1; + private static ImportItem tieBreak(ImportItem item1, ImportItem item2) + => isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1; - private static bool isUnavailable(ImportItem item) - => isFutureRelease(item) || isPlusTitleUnavailable(item); + private static bool isUnavailable(ImportItem item) + => isFutureRelease(item) || isPlusTitleUnavailable(item); private static bool isFutureRelease(ImportItem item) - => item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow; + => item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow; - private static bool isPlusTitleUnavailable(ImportItem item) - => item.DtoItem.ContentType is null - || (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true); - } -} + private static bool isPlusTitleUnavailable(ImportItem item) + => item.DtoItem.ContentType is null + || (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true); + + /// + /// Determines when your audible plus or free book will expire from your library + /// plan.IsAyce from underlying AudibleApi project determines the plans to look at, first plan found is used. + /// In some cases current date is later than end date so exclude. + /// + /// The DateTime that this title will become unavailable, otherwise null + private static DateTime? GetExpirationDate(ImportItem item) + => item.DtoItem.Plans + ?.Where(p => p.IsAyce) + .Select(p => p.EndDate) + .FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now) + ?.DateTime; + } +} \ No newline at end of file diff --git a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs index ee35a855..029b46f3 100644 --- a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs @@ -4,6 +4,7 @@ using Dinah.Core.ErrorHandling; using LibationAvalonia.ViewModels; using LibationFileManager; using LibationUiBase.ProcessQueue; +using System; using System.Collections.Generic; using System.IO; using System.Linq; diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index f51a3044..473a8592 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -477,6 +477,7 @@ namespace LibationAvalonia.ViewModels public DataGridLength PurchaseDateWidth { get => getColumnWidth("PurchaseDate", 75); set => setColumnWidth("PurchaseDate", value); } public DataGridLength MyRatingWidth { get => getColumnWidth("MyRating", 95); set => setColumnWidth("MyRating", value); } public DataGridLength MiscWidth { get => getColumnWidth("Misc", 140); set => setColumnWidth("Misc", value); } + public DataGridLength IncludedUntilWidth { get => getColumnWidth("IncludedUntil", 140); set => setColumnWidth("IncludedUntil", value); } public DataGridLength LastDownloadWidth { get => getColumnWidth("LastDownload", 100); set => setColumnWidth("LastDownload", value); } public DataGridLength BookTagsWidth { get => getColumnWidth("BookTags", 100); set => setColumnWidth("BookTags", value); } public DataGridLength IsSpatialWidth { get => getColumnWidth("IsSpatial", 100); set => setColumnWidth("IsSpatial", value); } diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml b/Source/LibationAvalonia/Views/ProductsDisplay.axaml index 8f2ec9c2..287d2da0 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml @@ -213,6 +213,16 @@ + + + + + + + + + + diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index 392c5ecf..e5f56e99 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -209,7 +209,8 @@ namespace LibationFileManager private static readonly EquatableDictionary DefaultColumns = new([ new ("SeriesOrder", false), new ("LastDownload", false), - new ("IsSpatial", false) + new ("IsSpatial", false), + new ("IncludedUntil", false), ]); public bool GetColumnVisibility(string columnName) => GridColumnsVisibilities.TryGetValue(columnName, out var isVisible) ? isVisible diff --git a/Source/LibationUiBase/GridView/GridEntry.cs b/Source/LibationUiBase/GridView/GridEntry.cs index 665859f1..06cb52d8 100644 --- a/Source/LibationUiBase/GridView/GridEntry.cs +++ b/Source/LibationUiBase/GridView/GridEntry.cs @@ -49,6 +49,7 @@ namespace LibationUiBase.GridView private string _bookTags; private Rating _myRating; private bool _isSpatial; + private string _includedUntil; public abstract bool? Remove { get; set; } public EntryStatus Liberate { get => _liberate; private set => RaiseAndSetIfChanged(ref _liberate, value); } public string PurchaseDate { get => _purchasedate; protected set => RaiseAndSetIfChanged(ref _purchasedate, value); } @@ -66,6 +67,7 @@ namespace LibationUiBase.GridView public Rating ProductRating { get => _productrating; private set => RaiseAndSetIfChanged(ref _productrating, value); } public string BookTags { get => _bookTags; private set => RaiseAndSetIfChanged(ref _bookTags, value); } public bool IsSpatial { get => _isSpatial; protected set => RaiseAndSetIfChanged(ref _isSpatial, value); } + public string IncludedUntil { get => _includedUntil; protected set => RaiseAndSetIfChanged(ref _includedUntil, value); } public Rating MyRating { @@ -120,14 +122,18 @@ namespace LibationUiBase.GridView SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; BookTags = GetBookTags(); IsSpatial = Book.IsSpatial; + IncludedUntil = GetIncludedUntilString(); + UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; } protected abstract string GetBookTags(); protected virtual DateTime GetPurchaseDate() => LibraryBook.DateAdded; + protected virtual DateTime? GetIncludedUntil() => LibraryBook.IncludedUntil; protected virtual int GetLengthInMinutes() => Book.LengthInMinutes; protected string GetPurchaseDateString() => GetPurchaseDate().ToString("d"); + protected string GetIncludedUntilString() => GetIncludedUntil()?.ToString("d") ?? string.Empty; protected string GetBookLengthString() { int bookLenMins = GetLengthInMinutes(); @@ -208,6 +214,7 @@ namespace LibationUiBase.GridView nameof(Liberate) => Liberate, nameof(DateAdded) => DateAdded, nameof(IsSpatial) => IsSpatial, + nameof(IncludedUntil) => GetIncludedUntil() ?? default, _ => null }; diff --git a/Source/LibationUiBase/GridView/SeriesEntry.cs b/Source/LibationUiBase/GridView/SeriesEntry.cs index 3706a23b..998d8842 100644 --- a/Source/LibationUiBase/GridView/SeriesEntry.cs +++ b/Source/LibationUiBase/GridView/SeriesEntry.cs @@ -102,5 +102,6 @@ namespace LibationUiBase.GridView protected override string GetBookTags() => null; protected override int GetLengthInMinutes() => Children.Count == 0 ? 0 : Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); protected override DateTime GetPurchaseDate() => Children.Count == 0 ? default : Children.Min(c => c.LibraryBook.DateAdded); + protected override DateTime? GetIncludedUntil() => Children.Count == 0 ? default : Children.Min(c => c.LibraryBook.IncludedUntil); } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs index 3f004673..21874afa 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs @@ -54,6 +54,7 @@ namespace LibationWinForms.GridView lastDownloadedGVColumn = new LastDownloadedGridViewColumn(); isSpatialGVColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn(); tagAndDetailsGVColumn = new EditTagsDataGridViewImageButtonColumn(); + includedUntilGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); ((System.ComponentModel.ISupportInitialize)gridEntryDataGridView).BeginInit(); ((System.ComponentModel.ISupportInitialize)syncBindingSource).BeginInit(); SuspendLayout(); @@ -66,7 +67,7 @@ namespace LibationWinForms.GridView gridEntryDataGridView.AllowUserToResizeRows = false; gridEntryDataGridView.AutoGenerateColumns = false; gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; - gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { removeGVColumn, liberateGVColumn, coverGVColumn, titleGVColumn, authorsGVColumn, narratorsGVColumn, lengthGVColumn, seriesGVColumn, seriesOrderGVColumn, descriptionGVColumn, categoryGVColumn, productRatingGVColumn, purchaseDateGVColumn, myRatingGVColumn, miscGVColumn, lastDownloadedGVColumn, isSpatialGVColumn, tagAndDetailsGVColumn }); + gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { removeGVColumn, liberateGVColumn, coverGVColumn, titleGVColumn, authorsGVColumn, narratorsGVColumn, lengthGVColumn, seriesGVColumn, seriesOrderGVColumn, descriptionGVColumn, categoryGVColumn, productRatingGVColumn, purchaseDateGVColumn, myRatingGVColumn, miscGVColumn, lastDownloadedGVColumn, isSpatialGVColumn, tagAndDetailsGVColumn, includedUntilGVColumn }); gridEntryDataGridView.ContextMenuStrip = showHideColumnsContextMenuStrip; gridEntryDataGridView.DataSource = syncBindingSource; dataGridViewCellStyle2.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; @@ -276,6 +277,16 @@ namespace LibationWinForms.GridView tagAndDetailsGVColumn.ScaleFactor = 0F; tagAndDetailsGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; // + // includedUntilGVColumn + // + includedUntilGVColumn.DataPropertyName = "IncludedUntil"; + includedUntilGVColumn.HeaderText = "Included Until"; + includedUntilGVColumn.MinimumWidth = 10; + includedUntilGVColumn.Name = "includedUntilGVColumn"; + includedUntilGVColumn.ReadOnly = true; + includedUntilGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + includedUntilGVColumn.Width = 108; + // // ProductsGrid // AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); @@ -314,5 +325,6 @@ namespace LibationWinForms.GridView private LastDownloadedGridViewColumn lastDownloadedGVColumn; private System.Windows.Forms.DataGridViewCheckBoxColumn isSpatialGVColumn; private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn includedUntilGVColumn; } }