From 90eccbf2f60a003b64b310aeb7c1b07c8ffbfba1 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Mon, 17 Jul 2023 08:55:55 -0600 Subject: [PATCH 1/6] Fix FilePathCache NRE (#680) --- Source/LibationFileManager/FilePathCache.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/LibationFileManager/FilePathCache.cs b/Source/LibationFileManager/FilePathCache.cs index 78cd990e..9cf34adb 100644 --- a/Source/LibationFileManager/FilePathCache.cs +++ b/Source/LibationFileManager/FilePathCache.cs @@ -56,11 +56,11 @@ namespace LibationFileManager ?.FirstOrDefault() ?.Path; - private static List getEntries(Func predicate) + private static IEnumerable getEntries(Func predicate) { var entries = cache.Where(predicate).ToList(); if (entries is null || !entries.Any()) - return null; + return Enumerable.Empty(); remove(entries.Where(e => !File.Exists(e.Path)).ToList()); From ea6adeb58f666d209c2e8dabd0656857871a030e Mon Sep 17 00:00:00 2001 From: Mbucari Date: Mon, 17 Jul 2023 16:50:45 -0600 Subject: [PATCH 2/6] Add category ladders --- Source/ApplicationServices/LibraryExporter.cs | 2 +- .../Configurations/BookCategoryConfig.cs | 26 + Source/DataLayer/Configurations/BookConfig.cs | 7 +- .../Configurations/CategoryConfig.cs | 7 +- .../Configurations/CategoryLadderConfig.cs | 24 + Source/DataLayer/EfClasses/Book.cs | 42 +- Source/DataLayer/EfClasses/BookCategory.cs | 20 + Source/DataLayer/EfClasses/Category.cs | 28 +- Source/DataLayer/EfClasses/CategoryLadder.cs | 48 ++ Source/DataLayer/EntityExtensions.cs | 25 +- Source/DataLayer/LibationContext.cs | 5 +- ...230717220642_AddCategoriesList.Designer.cs | 479 ++++++++++++++++++ .../20230717220642_AddCategoriesList.cs | 176 +++++++ .../LibationContextModelSnapshot.cs | 111 +++- Source/DataLayer/QueryObjects/BookQueries.cs | 2 +- .../QueryObjects/LibraryBookQueries.cs | 2 +- Source/DtoImporterService/BookImporter.cs | 27 +- Source/DtoImporterService/CategoryImporter.cs | 64 ++- Source/FileLiberator/DownloadDecryptBook.cs | 2 +- .../Dialogs/BookDetailsDialog.axaml.cs | 2 +- Source/LibationSearchEngine/SearchEngine.cs | 2 +- .../GridView/GridEntry[TStatus].cs | 2 +- .../Dialogs/BookDetailsDialog.cs | 2 +- 23 files changed, 983 insertions(+), 122 deletions(-) create mode 100644 Source/DataLayer/Configurations/BookCategoryConfig.cs create mode 100644 Source/DataLayer/Configurations/CategoryLadderConfig.cs create mode 100644 Source/DataLayer/EfClasses/BookCategory.cs create mode 100644 Source/DataLayer/EfClasses/CategoryLadder.cs create mode 100644 Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs create mode 100644 Source/DataLayer/Migrations/20230717220642_AddCategoriesList.cs diff --git a/Source/ApplicationServices/LibraryExporter.cs b/Source/ApplicationServices/LibraryExporter.cs index 29a0d74f..def72c3d 100644 --- a/Source/ApplicationServices/LibraryExporter.cs +++ b/Source/ApplicationServices/LibraryExporter.cs @@ -141,7 +141,7 @@ namespace ApplicationServices PictureId = a.Book.PictureId, IsAbridged = a.Book.IsAbridged, DatePublished = a.Book.DatePublished, - CategoriesNames = a.Book.CategoriesNames().Any() ? a.Book.CategoriesNames().Aggregate((a, b) => $"{a}, {b}") : "", + CategoriesNames = a.Book.LowestCategoryNames().Any() ? a.Book.LowestCategoryNames().Aggregate((a, b) => $"{a}, {b}") : "", MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating, MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating, MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating, diff --git a/Source/DataLayer/Configurations/BookCategoryConfig.cs b/Source/DataLayer/Configurations/BookCategoryConfig.cs new file mode 100644 index 00000000..8751d31b --- /dev/null +++ b/Source/DataLayer/Configurations/BookCategoryConfig.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer.Configurations +{ + internal class BookCategoryConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(bc => new { bc.BookId, bc.CategoryLadderId }); + + entity.HasIndex(bc => bc.BookId); + entity.HasIndex(bc => bc.CategoryLadderId); + + entity + .HasOne(bc => bc.Book) + .WithMany(b => b.CategoriesLink) + .HasForeignKey(bc => bc.BookId); + + entity + .HasOne(bc => bc.CategoryLadder) + .WithMany(c => c.BooksLink) + .HasForeignKey(bc => bc.CategoryLadderId); + } + } +} diff --git a/Source/DataLayer/Configurations/BookConfig.cs b/Source/DataLayer/Configurations/BookConfig.cs index af5bad8a..ccd505b7 100644 --- a/Source/DataLayer/Configurations/BookConfig.cs +++ b/Source/DataLayer/Configurations/BookConfig.cs @@ -72,9 +72,10 @@ namespace DataLayer.Configurations .SetPropertyAccessMode(PropertyAccessMode.Field); entity - .HasOne(b => b.Category) - .WithMany() - .HasForeignKey(b => b.CategoryId); + .Metadata + .FindNavigation(nameof(Book.CategoriesLink)) + // PropertyAccessMode.Field : Categories is a get-only property, not a field, so use its backing field + .SetPropertyAccessMode(PropertyAccessMode.Field); } } } \ No newline at end of file diff --git a/Source/DataLayer/Configurations/CategoryConfig.cs b/Source/DataLayer/Configurations/CategoryConfig.cs index fc7c72fb..3d62776d 100644 --- a/Source/DataLayer/Configurations/CategoryConfig.cs +++ b/Source/DataLayer/Configurations/CategoryConfig.cs @@ -10,8 +10,11 @@ namespace DataLayer.Configurations entity.HasKey(c => c.CategoryId); entity.HasIndex(c => c.AudibleCategoryId); - // seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs - entity.HasData(Category.GetEmpty()); + entity.Ignore(c => c.CategoryLadders); + + entity + .HasMany(e => e._categoryLadders) + .WithMany(e => e._categories); } } } \ No newline at end of file diff --git a/Source/DataLayer/Configurations/CategoryLadderConfig.cs b/Source/DataLayer/Configurations/CategoryLadderConfig.cs new file mode 100644 index 00000000..aa28b885 --- /dev/null +++ b/Source/DataLayer/Configurations/CategoryLadderConfig.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore; + +namespace DataLayer.Configurations +{ + internal class CategoryLadderConfig : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder entity) + { + entity.HasKey(cl => cl.CategoryLadderId); + + entity.Ignore(cl => cl.Categories); + + entity + .HasMany(cl => cl._categories) + .WithMany(c => c._categoryLadders); + + entity + .Metadata + .FindNavigation(nameof(CategoryLadder.BooksLink)) + .SetPropertyAccessMode(PropertyAccessMode.Field); + } + } +} diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 2492a5dd..d42a0bdd 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -55,10 +55,6 @@ namespace DataLayer public DateTime? DatePublished { get; private set; } public string Language { get; private set; } - // non-null. use "empty pattern" - internal int CategoryId { get; private set; } - public Category Category { get; private set; } - // is owned, not optional 1:1 public UserDefinedItem UserDefinedItem { get; private set; } @@ -79,7 +75,6 @@ namespace DataLayer ContentType contentType, IEnumerable authors, IEnumerable narrators, - Category category, string localeName) { // validate @@ -96,11 +91,10 @@ namespace DataLayer // non-ef-ctor init.s UserDefinedItem = new UserDefinedItem(this); _contributorsLink = new HashSet(); + _categoriesLink = new HashSet(); _seriesLink = new HashSet(); _supplements = new HashSet(); - Category = category; - // simple assigns UpdateTitle(title, subtitle); Description = description?.Trim() ?? ""; @@ -182,9 +176,30 @@ namespace DataLayer return entry; } + #region categories + private HashSet _categoriesLink; + public IEnumerable CategoriesLink => _categoriesLink?.ToList(); + public void UpsertCategories(CategoryLadder ladder) + { + ArgumentValidator.EnsureNotNull(ladder, nameof(ladder)); - #region series - private HashSet _seriesLink; + var singleBookCategory = _categoriesLink.SingleOrDefault(bc => bc.CategoryLadder.Equals(ladder)); + + if (singleBookCategory is null) + _categoriesLink.Add(new BookCategory(this, ladder)); + else + { + for (var i = 0; i < ladder._categories.Count; i++) + { + //Update the category name + singleBookCategory.CategoryLadder._categories[i].Name = ladder._categories[i].Name; + } + } + } + #endregion + + #region series + private HashSet _seriesLink; public IEnumerable SeriesLink => _seriesLink?.ToList(); public void UpsertSeries(Series series, string order, DbContext context = null) @@ -234,15 +249,6 @@ namespace DataLayer Language = language?.FirstCharToUpper() ?? Language; } - public void UpdateCategory(Category category, DbContext context = null) - { - // since category is never null, nullity means it hasn't been loaded - if (Category is null) - getEntry(context).Reference(s => s.Category).Load(); - - Category = category; - } - public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}"; } } diff --git a/Source/DataLayer/EfClasses/BookCategory.cs b/Source/DataLayer/EfClasses/BookCategory.cs new file mode 100644 index 00000000..34139aa8 --- /dev/null +++ b/Source/DataLayer/EfClasses/BookCategory.cs @@ -0,0 +1,20 @@ +using Dinah.Core; + +namespace DataLayer +{ + public class BookCategory + { + internal int BookId { get; private set; } + internal int CategoryLadderId { get; private set; } + + public Book Book { get; private set; } + public CategoryLadder CategoryLadder { get; private set; } + private BookCategory() { } + + internal BookCategory(Book book, CategoryLadder categoriesList) + { + Book = ArgumentValidator.EnsureNotNull(book, nameof(book)); + CategoryLadder = ArgumentValidator.EnsureNotNull(categoriesList, nameof(categoriesList)); + } + } +} diff --git a/Source/DataLayer/EfClasses/Category.cs b/Source/DataLayer/EfClasses/Category.cs index 2060b72e..7e992f26 100644 --- a/Source/DataLayer/EfClasses/Category.cs +++ b/Source/DataLayer/EfClasses/Category.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Generic; using Dinah.Core; -using Microsoft.EntityFrameworkCore; namespace DataLayer { @@ -15,20 +12,20 @@ namespace DataLayer Id = id; } } + public class Category { - // Empty is a special case. use private ctor w/o validation - public static Category GetEmpty() => new() { CategoryId = -1, AudibleCategoryId = "", Name = "" }; - internal int CategoryId { get; private set; } public string AudibleCategoryId { get; private set; } - public string Name { get; private set; } - public Category ParentCategory { get; private set; } + public string Name { get; internal set; } - private Category() { } + internal List _categoryLadders = new(); + public IReadOnlyCollection CategoryLadders => _categoryLadders.AsReadOnly(); + + private Category() { } /// special id class b/c it's too easy to get string order mixed up - public Category(AudibleCategoryId audibleSeriesId, string name, Category parentCategory = null) + public Category(AudibleCategoryId audibleSeriesId, string name) { ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId)); var id = audibleSeriesId.Id; @@ -37,15 +34,6 @@ namespace DataLayer AudibleCategoryId = id; Name = name; - - UpdateParentCategory(parentCategory); - } - - public void UpdateParentCategory(Category parentCategory) - { - // don't overwrite with null but not an error - if (parentCategory is not null) - ParentCategory = parentCategory; } public override string ToString() => $"[{AudibleCategoryId}] {Name}"; diff --git a/Source/DataLayer/EfClasses/CategoryLadder.cs b/Source/DataLayer/EfClasses/CategoryLadder.cs new file mode 100644 index 00000000..27f067ad --- /dev/null +++ b/Source/DataLayer/EfClasses/CategoryLadder.cs @@ -0,0 +1,48 @@ +using Dinah.Core; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace DataLayer +{ + public class CategoryLadder : IEquatable + { + internal int CategoryLadderId { get; private set; } + + internal List _categories; + public ReadOnlyCollection Categories => _categories.AsReadOnly(); + + private HashSet _booksLink; + public IEnumerable BooksLink => _booksLink?.ToList(); + private CategoryLadder() { _categories = new(); } + public CategoryLadder(List categories) + { + ArgumentValidator.EnsureNotNull(categories, nameof(categories)); + ArgumentValidator.EnsureGreaterThan(categories.Count, nameof(categories), 0); + _booksLink = new HashSet(); + _categories = categories; + } + + public override int GetHashCode() + { + HashCode hashCode = default; + foreach (var category in _categories) + hashCode.Add(category.AudibleCategoryId); + return hashCode.ToHashCode(); + } + + public bool Equals(CategoryLadder other) + { + if (other?._categories is null) + return false; + + return Equals(other._categories.Select(c => c.AudibleCategoryId)); + } + public bool Equals(IEnumerable categoryIds) + => _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds); + public override bool Equals(object obj) => obj is CategoryLadder other && Equals(other); + + public override string ToString() => string.Join(" > ", _categories.Select(c => c.Name)); + } +} diff --git a/Source/DataLayer/EntityExtensions.cs b/Source/DataLayer/EntityExtensions.cs index 97b6ff8d..d91e7eb1 100644 --- a/Source/DataLayer/EntityExtensions.cs +++ b/Source/DataLayer/EntityExtensions.cs @@ -46,14 +46,23 @@ namespace DataLayer ? $"{sb.Series.Name} (#{sb.Order})" : sb.Series.Name; } - public static string[] CategoriesNames(this Book book) - => book.Category is null ? new string[0] - : book.Category.ParentCategory is null ? new[] { book.Category.Name } - : new[] { book.Category.ParentCategory.Name, book.Category.Name }; - public static string[] CategoriesIds(this Book book) - => book.Category is null ? null - : book.Category.ParentCategory is null ? new[] { book.Category.AudibleCategoryId } - : new[] { book.Category.ParentCategory.AudibleCategoryId, book.Category.AudibleCategoryId }; + + public static string[] LowestCategoryNames(this Book book) + => book.CategoriesLink?.Any() is not true ? Array.Empty() + : book + .CategoriesLink + .Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name) + .Where(c => c is not null) + .Distinct() + .ToArray(); + + public static string[] CategoriesIds(this Book book) + => book.CategoriesLink?.Any() is not true ? null + : book + .CategoriesLink + .SelectMany(cl => cl.CategoryLadder.Categories) + .Select(c => c.AudibleCategoryId) + .ToArray(); public static string AggregateTitles(this IEnumerable libraryBooks, int max = 5) { diff --git a/Source/DataLayer/LibationContext.cs b/Source/DataLayer/LibationContext.cs index 8ae3a545..f4e773c3 100644 --- a/Source/DataLayer/LibationContext.cs +++ b/Source/DataLayer/LibationContext.cs @@ -23,6 +23,7 @@ namespace DataLayer public DbSet Contributors { get; private set; } public DbSet Series { get; private set; } public DbSet Categories { get; private set; } + public DbSet CategoryLadders { get; private set; } public static LibationContext Create(string connectionString) { @@ -39,13 +40,15 @@ namespace DataLayer { base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfiguration(new BookConfig()); + modelBuilder.ApplyConfiguration(new BookConfig()); modelBuilder.ApplyConfiguration(new ContributorConfig()); modelBuilder.ApplyConfiguration(new BookContributorConfig()); modelBuilder.ApplyConfiguration(new LibraryBookConfig()); modelBuilder.ApplyConfiguration(new SeriesConfig()); modelBuilder.ApplyConfiguration(new SeriesBookConfig()); modelBuilder.ApplyConfiguration(new CategoryConfig()); + modelBuilder.ApplyConfiguration(new CategoryLadderConfig()); + modelBuilder.ApplyConfiguration(new BookCategoryConfig()); // views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"): // https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types diff --git a/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs b/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs new file mode 100644 index 00000000..f9adfb8b --- /dev/null +++ b/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs @@ -0,0 +1,479 @@ +// +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("20230717220642_AddCategoriesList")] + partial class AddCategoriesList + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); + + 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("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.Property("_audioFormat") + .HasColumnType("INTEGER"); + + 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"); + + b.HasData( + new + { + CategoryId = -1, + AudibleCategoryId = "", + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Property("CategoryLadderId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("CategoryLadderId"); + + b.ToTable("CategoryLadders"); + + b.HasData( + new + { + CategoryLadderId = -1 + }); + }); + + 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("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("LastDownloaded") + .HasColumnType("TEXT"); + + 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/20230717220642_AddCategoriesList.cs b/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.cs new file mode 100644 index 00000000..0121e911 --- /dev/null +++ b/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.cs @@ -0,0 +1,176 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// + public partial class AddCategoriesList : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books"); + + migrationBuilder.DropForeignKey( + name: "FK_Categories_Categories_ParentCategoryCategoryId", + table: "Categories"); + + migrationBuilder.DropIndex( + name: "IX_Categories_ParentCategoryCategoryId", + table: "Categories"); + + migrationBuilder.DropIndex( + name: "IX_Books_CategoryId", + table: "Books"); + + migrationBuilder.DropColumn( + name: "ParentCategoryCategoryId", + table: "Categories"); + + migrationBuilder.DropColumn( + name: "CategoryId", + table: "Books"); + + migrationBuilder.CreateTable( + name: "CategoryLadders", + columns: table => new + { + CategoryLadderId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true) + }, + constraints: table => + { + table.PrimaryKey("PK_CategoryLadders", x => x.CategoryLadderId); + }); + + migrationBuilder.CreateTable( + name: "BookCategory", + columns: table => new + { + BookId = table.Column(type: "INTEGER", nullable: false), + CategoryLadderId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BookCategory", x => new { x.BookId, x.CategoryLadderId }); + table.ForeignKey( + name: "FK_BookCategory_Books_BookId", + column: x => x.BookId, + principalTable: "Books", + principalColumn: "BookId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_BookCategory_CategoryLadders_CategoryLadderId", + column: x => x.CategoryLadderId, + principalTable: "CategoryLadders", + principalColumn: "CategoryLadderId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CategoryCategoryLadder", + columns: table => new + { + _categoriesCategoryId = table.Column(type: "INTEGER", nullable: false), + _categoryLaddersCategoryLadderId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CategoryCategoryLadder", x => new { x._categoriesCategoryId, x._categoryLaddersCategoryLadderId }); + table.ForeignKey( + name: "FK_CategoryCategoryLadder_Categories__categoriesCategoryId", + column: x => x._categoriesCategoryId, + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CategoryCategoryLadder_CategoryLadders__categoryLaddersCategoryLadderId", + column: x => x._categoryLaddersCategoryLadderId, + principalTable: "CategoryLadders", + principalColumn: "CategoryLadderId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "CategoryLadders", + column: "CategoryLadderId", + value: -1); + + migrationBuilder.CreateIndex( + name: "IX_BookCategory_BookId", + table: "BookCategory", + column: "BookId"); + + migrationBuilder.CreateIndex( + name: "IX_BookCategory_CategoryLadderId", + table: "BookCategory", + column: "CategoryLadderId"); + + migrationBuilder.CreateIndex( + name: "IX_CategoryCategoryLadder__categoryLaddersCategoryLadderId", + table: "CategoryCategoryLadder", + column: "_categoryLaddersCategoryLadderId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BookCategory"); + + migrationBuilder.DropTable( + name: "CategoryCategoryLadder"); + + migrationBuilder.DropTable( + name: "CategoryLadders"); + + migrationBuilder.AddColumn( + name: "ParentCategoryCategoryId", + table: "Categories", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "CategoryId", + table: "Books", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.UpdateData( + table: "Categories", + keyColumn: "CategoryId", + keyValue: -1, + column: "ParentCategoryCategoryId", + value: null); + + migrationBuilder.CreateIndex( + name: "IX_Categories_ParentCategoryCategoryId", + table: "Categories", + column: "ParentCategoryCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Books_CategoryId", + table: "Books", + column: "CategoryId"); + + migrationBuilder.AddForeignKey( + name: "FK_Books_Categories_CategoryId", + table: "Books", + column: "CategoryId", + principalTable: "Categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_Categories_Categories_ParentCategoryCategoryId", + table: "Categories", + column: "ParentCategoryCategoryId", + principalTable: "Categories", + principalColumn: "CategoryId"); + } + } +} diff --git a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs index 65d792b1..bafd6dfa 100644 --- a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs @@ -17,6 +17,21 @@ namespace DataLayer.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); + 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") @@ -26,9 +41,6 @@ namespace DataLayer.Migrations b.Property("AudibleProductId") .HasColumnType("TEXT"); - b.Property("CategoryId") - .HasColumnType("INTEGER"); - b.Property("ContentType") .HasColumnType("INTEGER"); @@ -69,11 +81,26 @@ namespace DataLayer.Migrations b.HasIndex("AudibleProductId"); - b.HasIndex("CategoryId"); - 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") @@ -109,15 +136,10 @@ namespace DataLayer.Migrations b.Property("Name") .HasColumnType("TEXT"); - b.Property("ParentCategoryCategoryId") - .HasColumnType("INTEGER"); - b.HasKey("CategoryId"); b.HasIndex("AudibleCategoryId"); - b.HasIndex("ParentCategoryCategoryId"); - b.ToTable("Categories"); b.HasData( @@ -129,6 +151,23 @@ namespace DataLayer.Migrations }); }); + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Property("CategoryLadderId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("CategoryLadderId"); + + b.ToTable("CategoryLadders"); + + b.HasData( + new + { + CategoryLadderId = -1 + }); + }); + modelBuilder.Entity("DataLayer.Contributor", b => { b.Property("ContributorId") @@ -216,14 +255,23 @@ namespace DataLayer.Migrations b.ToTable("SeriesBook"); }); - modelBuilder.Entity("DataLayer.Book", b => + modelBuilder.Entity("CategoryCategoryLadder", b => { - b.HasOne("DataLayer.Category", "Category") + b.HasOne("DataLayer.Category", null) .WithMany() - .HasForeignKey("CategoryId") + .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") @@ -324,8 +372,6 @@ namespace DataLayer.Migrations b1.Navigation("Rating"); }); - b.Navigation("Category"); - b.Navigation("Rating"); b.Navigation("Supplements"); @@ -333,6 +379,25 @@ namespace DataLayer.Migrations 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") @@ -352,15 +417,6 @@ namespace DataLayer.Migrations b.Navigation("Contributor"); }); - modelBuilder.Entity("DataLayer.Category", b => - { - b.HasOne("DataLayer.Category", "ParentCategory") - .WithMany() - .HasForeignKey("ParentCategoryCategoryId"); - - b.Navigation("ParentCategory"); - }); - modelBuilder.Entity("DataLayer.LibraryBook", b => { b.HasOne("DataLayer.Book", "Book") @@ -393,11 +449,18 @@ namespace DataLayer.Migrations 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"); diff --git a/Source/DataLayer/QueryObjects/BookQueries.cs b/Source/DataLayer/QueryObjects/BookQueries.cs index 85f9153b..4a3c9171 100644 --- a/Source/DataLayer/QueryObjects/BookQueries.cs +++ b/Source/DataLayer/QueryObjects/BookQueries.cs @@ -34,7 +34,7 @@ namespace DataLayer // owned items are always loaded. eg: book.UserDefinedItem, book.Supplements .Include(b => b.SeriesLink).ThenInclude(sb => sb.Series) .Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor) - .Include(b => b.Category).ThenInclude(c => c.ParentCategory); + .Include(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories); public static bool IsProduct(this Book book) => book.ContentType is not ContentType.Episode and not ContentType.Parent; diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index f10350f5..476e617d 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -55,7 +55,7 @@ namespace DataLayer // owned items are always loaded. eg: book.UserDefinedItem, book.Supplements .Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series) .Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor) - .Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory); + .Include(le => le.Book).ThenInclude(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories); public static IEnumerable ParentedEpisodes(this IEnumerable libraryBooks) => libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren); diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index 14d5d196..40dc3fa3 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -99,20 +99,6 @@ namespace DtoImporterService .Select(n => contributorImporter.Cache[n.Name]) .ToList(); - // categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd - // absence of categories is also possible - - // CATEGORY HACK: only use the 1st 2 categories - // after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this - // var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? ""; - var lastCategory - = item.Categories.Length == 0 ? "" - : item.Categories.Length == 1 ? item.Categories[0].CategoryId - // 2+ - : item.Categories[1].CategoryId; - - var category = categoryImporter.Cache[lastCategory]; - Book book; try { @@ -125,7 +111,6 @@ namespace DtoImporterService contentType, authors, narrators, - category, importItem.LocaleName) ).Entity; Cache.Add(book.AudibleProductId, book); @@ -140,7 +125,6 @@ namespace DtoImporterService contentType, QtyAuthors = authors?.Count, QtyNarrators = narrators?.Count, - Category = category?.Name, importItem.LocaleName }); throw; @@ -201,6 +185,17 @@ namespace DtoImporterService book.UpsertSeries(series, seriesEntry.Sequence); } } + + if (item.CategoryLadders is not null) + { + foreach (var ladder in item.CategoryLadders.Select(cl => cl.Ladder).Where(l => l?.Length > 0)) + { + var categoryIds = ladder.Select(l => l.CategoryId).ToList(); + var cata = categoryImporter.LadderCache.Single(c => c.Equals(categoryIds)); + + book.UpsertCategories(cata); + } + } } private static DataLayer.ContentType GetContentType(Item item) diff --git a/Source/DtoImporterService/CategoryImporter.cs b/Source/DtoImporterService/CategoryImporter.cs index 5a3434c6..7f6c50fd 100644 --- a/Source/DtoImporterService/CategoryImporter.cs +++ b/Source/DtoImporterService/CategoryImporter.cs @@ -12,7 +12,8 @@ namespace DtoImporterService { protected override IValidator Validator => new CategoryValidator(); - public Dictionary Cache { get; private set; } = new(); + private Dictionary Cache { get; set; } = new(); + public HashSet LadderCache { get; private set; } = new(); public CategoryImporter(LibationContext context) : base(context) { } @@ -30,44 +31,39 @@ namespace DtoImporterService loadLocal_categories(categoryIds); // upsert - var categoryPairs = importItems - .Select(i => i.DtoItem) - .GetCategoryPairsDistinct() + var categoryLadders = importItems + .SelectMany(i => i.DtoItem.CategoryLadders) + .Select(cl => cl.Ladder) + .Where(l => l?.Length > 0) .ToList(); - var qtyNew = upsertCategories(categoryPairs); + + var qtyNew = upsertCategories(categoryLadders); return qtyNew; } private void loadLocal_categories(List categoryIds) { - // must include default/empty/missing - categoryIds.Add(Category.GetEmpty().AudibleCategoryId); - // load existing => local Cache = DbContext.Categories .Where(c => categoryIds.Contains(c.AudibleCategoryId)) .ToDictionarySafe(c => c.AudibleCategoryId); + + LadderCache = DbContext.CategoryLadders.ToHashSet(); } // only use after loading contributors => local - private int upsertCategories(List categoryPairs) + private int upsertCategories(List ladders) { var qtyNew = 0; - foreach (var pair in categoryPairs) + foreach (var ladder in ladders) { - for (var i = 0; i < pair.Length; i++) + var categories = new List(ladder.Length); + + for (var i = 0; i < ladder.Length; i++) { - // CATEGORY HACK: not yet supported: depth beyond 0 and 1 - if (i > 1) - break; - - var id = pair[i].CategoryId; - var name = pair[i].CategoryName; - - Category parentCategory = null; - if (i == 1) - Cache.TryGetValue(pair[0].CategoryId, out parentCategory); + var id = ladder[i].CategoryId; + var name = ladder[i].CategoryName; if (!Cache.TryGetValue(id, out var category)) { @@ -75,13 +71,37 @@ namespace DtoImporterService qtyNew++; } - category.UpdateParentCategory(parentCategory); + categories.Add(category); + } + + var categoryLadder = new DataLayer.CategoryLadder(categories); + if (!LadderCache.Contains(categoryLadder)) + { + addCategoryLadder(categoryLadder); + qtyNew++; } } return qtyNew; } + private DataLayer.CategoryLadder addCategoryLadder(DataLayer.CategoryLadder categoryList) + { + try + { + var entityEntry = DbContext.CategoryLadders.Add(categoryList); + var entity = entityEntry.Entity; + + LadderCache.Add(entity); + return entity; + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error adding category ladder. {@DebugInfo}", categoryList); + throw; + } + } + private Category addCategory(string id, string name) { try diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index cbcf2336..8ec11816 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -140,7 +140,7 @@ namespace FileLiberator new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions); if (config.AllowLibationFixup) - converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames()); + converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.LowestCategoryNames()); abDownloader = converter; } diff --git a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs index 49833015..da6f5fbb 100644 --- a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs @@ -115,7 +115,7 @@ Author(s): {Book.AuthorNames()} Narrator(s): {Book.NarratorNames()} Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")} Audio Bitrate: {Book.AudioFormat} -Category: {string.Join(" > ", Book.CategoriesNames())} +Category: {string.Join(", ", Book.LowestCategoryNames())} Purchase Date: {libraryBook.DateAdded:d} Language: {Book.Language} Audible ID: {Book.AudibleProductId} diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs index 061957f2..1b55d6e3 100644 --- a/Source/LibationSearchEngine/SearchEngine.cs +++ b/Source/LibationSearchEngine/SearchEngine.cs @@ -41,7 +41,7 @@ namespace LibationSearchEngine { FieldType.String, lb => lb.Book.Publisher, nameof(Book.Publisher) }, { FieldType.String, lb => lb.Book.SeriesNames(), "SeriesNames", "Narrator", "Series" }, { FieldType.String, lb => string.Join(", ", lb.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId)), "SeriesId" }, - { FieldType.String, lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()), nameof(Book.Category), "Categories", "CategoriesId", "CategoryId", "CategoriesNames" }, + { FieldType.String, lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()), "Category", "Categories", "CategoriesId", "CategoryId", "CategoriesNames" }, { FieldType.String, lb => lb.Book.UserDefinedItem.Tags, TAGS.FirstCharToUpper() }, { FieldType.String, lb => lb.Book.Locale, "Locale", "Region" }, { FieldType.String, lb => lb.Account, "Account", "Email" }, diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs index 93232ab6..9166c326 100644 --- a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs @@ -116,7 +116,7 @@ namespace LibationUiBase.GridView ProductRating = Book.Rating ?? new Rating(0, 0, 0); Authors = Book.AuthorNames(); Narrators = Book.NarratorNames(); - Category = string.Join(" > ", Book.CategoriesNames()); + Category = string.Join(", ", Book.LowestCategoryNames()); Misc = GetMiscDisplay(libraryBook); LastDownload = new(Book.UserDefinedItem); LongDescription = GetDescriptionDisplay(Book); diff --git a/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs b/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs index bad7ee86..c534e40b 100644 --- a/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs +++ b/Source/LibationWinForms/Dialogs/BookDetailsDialog.cs @@ -50,7 +50,7 @@ Author(s): {Book.AuthorNames()} Narrator(s): {Book.NarratorNames()} Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")} Audio Bitrate: {Book.AudioFormat} -Category: {string.Join(" > ", Book.CategoriesNames())} +Category: {string.Join(", ", Book.LowestCategoryNames())} Purchase Date: {_libraryBook.DateAdded:d} Language: {Book.Language} Audible ID: {Book.AudibleProductId} From 3211b2dc853b8aafb3fde488eb632fde7e8cd9a1 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Tue, 18 Jul 2023 08:59:42 -0600 Subject: [PATCH 3/6] Improved Category Ladders --- Source/DataLayer/Configurations/BookConfig.cs | 23 +--- Source/DataLayer/EfClasses/Book.cs | 111 +++++++++--------- Source/DataLayer/EfClasses/Category.cs | 18 ++- Source/DataLayer/EfClasses/CategoryLadder.cs | 34 ++++-- Source/DataLayer/EntityExtensions.cs | 9 +- ...30718214617_AddCategoryLadder.Designer.cs} | 18 +-- ...cs => 20230718214617_AddCategoryLadder.cs} | 20 ++-- .../LibationContextModelSnapshot.cs | 14 --- .../DataLayer/QueryObjects/CategoryQueries.cs | 11 ++ Source/DtoImporterService/BookImporter.cs | 8 +- Source/DtoImporterService/CategoryImporter.cs | 26 ++-- 11 files changed, 132 insertions(+), 160 deletions(-) rename Source/DataLayer/Migrations/{20230717220642_AddCategoriesList.Designer.cs => 20230718214617_AddCategoryLadder.Designer.cs} (96%) rename Source/DataLayer/Migrations/{20230717220642_AddCategoriesList.cs => 20230718214617_AddCategoryLadder.cs} (95%) create mode 100644 Source/DataLayer/QueryObjects/CategoryQueries.cs diff --git a/Source/DataLayer/Configurations/BookConfig.cs b/Source/DataLayer/Configurations/BookConfig.cs index ccd505b7..f19e490d 100644 --- a/Source/DataLayer/Configurations/BookConfig.cs +++ b/Source/DataLayer/Configurations/BookConfig.cs @@ -21,10 +21,7 @@ namespace DataLayer.Configurations entity.Ignore(nameof(Book.Narrators)); entity.Ignore(nameof(Book.AudioFormat)); entity.Ignore(nameof(Book.TitleWithSubtitle)); - //// these don't seem to matter - //entity.Ignore(nameof(Book.AuthorNames)); - //entity.Ignore(nameof(Book.NarratorNames)); - //entity.Ignore(nameof(Book.HasPdfs)); + entity.Ignore(b => b.Categories); // OwnsMany: "Can only ever appear on navigation properties of other entity types. // Are automatically loaded, and can only be tracked by a DbContext alongside their owner." @@ -58,24 +55,6 @@ namespace DataLayer.Configurations // owns it 1:1, store in same table b_udi.OwnsOne(udi => udi.Rating); }); - - entity - .Metadata - .FindNavigation(nameof(Book.ContributorsLink)) - // PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field - .SetPropertyAccessMode(PropertyAccessMode.Field); - - entity - .Metadata - .FindNavigation(nameof(Book.SeriesLink)) - // PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field - .SetPropertyAccessMode(PropertyAccessMode.Field); - - entity - .Metadata - .FindNavigation(nameof(Book.CategoriesLink)) - // PropertyAccessMode.Field : Categories is a get-only property, not a field, so use its backing field - .SetPropertyAccessMode(PropertyAccessMode.Field); } } } \ No newline at end of file diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index d42a0bdd..b8bcce6b 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using Dinah.Core; using Microsoft.EntityFrameworkCore; @@ -37,7 +38,7 @@ namespace DataLayer public string Subtitle { get; private set; } private string _titleWithSubtitle; public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}"; - public string Description { get; private set; } + public string Description { get; private set; } public int LengthInMinutes { get; private set; } public ContentType ContentType { get; private set; } public string Locale { get; private set; } @@ -73,8 +74,8 @@ namespace DataLayer string description, int lengthInMinutes, ContentType contentType, - IEnumerable authors, - IEnumerable narrators, + IEnumerable authors, + IEnumerable narrators, string localeName) { // validate @@ -82,7 +83,7 @@ namespace DataLayer var productId = audibleProductId.Id; ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId)); - // assign as soon as possible. stuff below relies on this + // assign as soon as possible. stuff below relies on this AudibleProductId = productId; Locale = localeName; @@ -90,42 +91,34 @@ namespace DataLayer // non-ef-ctor init.s UserDefinedItem = new UserDefinedItem(this); - _contributorsLink = new HashSet(); - _categoriesLink = new HashSet(); + ContributorsLink = new HashSet(); + CategoriesLink = new HashSet(); _seriesLink = new HashSet(); _supplements = new HashSet(); // simple assigns UpdateTitle(title, subtitle); - Description = description?.Trim() ?? ""; + Description = description?.Trim() ?? ""; LengthInMinutes = lengthInMinutes; ContentType = contentType; // assigns with biz logic ReplaceAuthors(authors); ReplaceNarrators(narrators); - } - public void UpdateTitle(string title, string subtitle) + } + public void UpdateTitle(string title, string subtitle) { Title = title?.Trim() ?? ""; Subtitle = subtitle?.Trim() ?? ""; _titleWithSubtitle = null; - } + } - #region contributors, authors, narrators - // use uninitialised backing fields - this means we can detect if the collection was loaded - private HashSet _contributorsLink; - // i'd like this to be internal but migration throws this exception when i try: - // Value cannot be null. - // Parameter name: property - public IEnumerable ContributorsLink - => _contributorsLink? - .OrderBy(bc => bc.Order) - .ToList(); + #region contributors, authors, narrators + internal HashSet ContributorsLink { get; private set; } - public IEnumerable Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList(); - public IEnumerable Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList(); - public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name; + public IEnumerable Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList(); + public IEnumerable Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList(); + public string Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name; public void ReplaceAuthors(IEnumerable authors, DbContext context = null) => replaceContributors(authors, Role.Author, context); @@ -138,63 +131,65 @@ namespace DataLayer ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors)); // the edge cases of doing local-loaded vs remote-only got weird. just load it - if (_contributorsLink is null) - getEntry(context).Collection(s => s.ContributorsLink).Load(); + if (ContributorsLink is null) + getEntry(context).Collection(s => s.ContributorsLink).Load(); + + var isIdentical + = ContributorsLink + .ByRole(role) + .Select(c => c.Contributor) + .SequenceEqual(newContributors); - var roleContributions = getContributions(role); - var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors); if (isIdentical) return; - _contributorsLink.RemoveWhere(bc => bc.Role == role); + ContributorsLink.RemoveWhere(bc => bc.Role == role); addNewContributors(newContributors, role); } - private void addNewContributors(IEnumerable newContributors, Role role) + private void addNewContributors(IEnumerable newContributors, Role role) { byte order = 0; var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++)); var newContributions = new HashSet(newContributionsEnum); - _contributorsLink.UnionWith(newContributions); + ContributorsLink.UnionWith(newContributions); } - private List getContributions(Role role) - => ContributorsLink - .Where(a => a.Role == role) - .OrderBy(a => a.Order) - .ToList(); #endregion - private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry getEntry(DbContext context) - { - ArgumentValidator.EnsureNotNull(context, nameof(context)); + private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry getEntry(DbContext context) + { + ArgumentValidator.EnsureNotNull(context, nameof(context)); - var entry = context.Entry(this); + var entry = context.Entry(this); - if (!entry.IsKeySet) - throw new InvalidOperationException("Could not load a valid Book from database"); + if (!entry.IsKeySet) + throw new InvalidOperationException("Could not load a valid Book from database"); - return entry; - } - #region categories - private HashSet _categoriesLink; - public IEnumerable CategoriesLink => _categoriesLink?.ToList(); - public void UpsertCategories(CategoryLadder ladder) - { - ArgumentValidator.EnsureNotNull(ladder, nameof(ladder)); + return entry; + } - var singleBookCategory = _categoriesLink.SingleOrDefault(bc => bc.CategoryLadder.Equals(ladder)); + #region categories + internal HashSet CategoriesLink { get; private set; } - if (singleBookCategory is null) - _categoriesLink.Add(new BookCategory(this, ladder)); - else + private ReadOnlyCollection _categoriesReadOnly; + public ReadOnlyCollection Categories + { + get { - for (var i = 0; i < ladder._categories.Count; i++) - { - //Update the category name - singleBookCategory.CategoryLadder._categories[i].Name = ladder._categories[i].Name; - } + if (_categoriesReadOnly?.SequenceEqual(CategoriesLink) is not true) + _categoriesReadOnly = CategoriesLink.ToList().AsReadOnly(); + return _categoriesReadOnly; } + } + public void SetCategoryLadders(IEnumerable ladders) + { + ArgumentValidator.EnsureNotNull(ladders, nameof(ladders)); + + //Replace all existing category ladders. + //Some books make have duplocate ladders + CategoriesLink.Clear(); + CategoriesLink.UnionWith(ladders.Distinct().Select(l => new BookCategory(this, l))); } #endregion diff --git a/Source/DataLayer/EfClasses/Category.cs b/Source/DataLayer/EfClasses/Category.cs index 7e992f26..e4654b3b 100644 --- a/Source/DataLayer/EfClasses/Category.cs +++ b/Source/DataLayer/EfClasses/Category.cs @@ -1,6 +1,9 @@ using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using Dinah.Core; +#nullable enable namespace DataLayer { public class AudibleCategoryId @@ -16,12 +19,21 @@ namespace DataLayer public class Category { internal int CategoryId { get; private set; } - public string AudibleCategoryId { get; private set; } + public string? AudibleCategoryId { get; private set; } - public string Name { get; internal set; } + public string? Name { get; internal set; } internal List _categoryLadders = new(); - public IReadOnlyCollection CategoryLadders => _categoryLadders.AsReadOnly(); + private ReadOnlyCollection? _categoryLaddersReadOnly; + public ReadOnlyCollection CategoryLadders + { + get + { + if (_categoryLaddersReadOnly?.SequenceEqual(_categoryLadders) is not true) + _categoryLaddersReadOnly = _categoryLadders.AsReadOnly(); + return _categoryLaddersReadOnly; + } + } private Category() { } /// special id class b/c it's too easy to get string order mixed up diff --git a/Source/DataLayer/EfClasses/CategoryLadder.cs b/Source/DataLayer/EfClasses/CategoryLadder.cs index 27f067ad..c2f6e52f 100644 --- a/Source/DataLayer/EfClasses/CategoryLadder.cs +++ b/Source/DataLayer/EfClasses/CategoryLadder.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +#nullable enable namespace DataLayer { public class CategoryLadder : IEquatable @@ -11,10 +12,19 @@ namespace DataLayer internal int CategoryLadderId { get; private set; } internal List _categories; - public ReadOnlyCollection Categories => _categories.AsReadOnly(); + private ReadOnlyCollection? _categoriesReadOnly; + public ReadOnlyCollection Categories + { + get + { + if (_categoriesReadOnly?.SequenceEqual(_categories) is not true) + _categoriesReadOnly = _categories.AsReadOnly(); + return _categoriesReadOnly; + } + } - private HashSet _booksLink; - public IEnumerable BooksLink => _booksLink?.ToList(); + private HashSet? _booksLink; + public IEnumerable? BooksLink => _booksLink?.ToList(); private CategoryLadder() { _categories = new(); } public CategoryLadder(List categories) { @@ -32,16 +42,16 @@ namespace DataLayer return hashCode.ToHashCode(); } - public bool Equals(CategoryLadder other) - { - if (other?._categories is null) - return false; + public bool Equals(CategoryLadder? other) + => other?._categories is not null + && Equals(other._categories.Select(c => c.AudibleCategoryId)); - return Equals(other._categories.Select(c => c.AudibleCategoryId)); - } - public bool Equals(IEnumerable categoryIds) - => _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds); - public override bool Equals(object obj) => obj is CategoryLadder other && Equals(other); + public bool Equals(IEnumerable? categoryIds) + => categoryIds is not null + && _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds); + + public override bool Equals(object? obj) + => obj is CategoryLadder other && Equals(other); public override string ToString() => string.Join(" > ", _categories.Select(c => c.Name)); } diff --git a/Source/DataLayer/EntityExtensions.cs b/Source/DataLayer/EntityExtensions.cs index d91e7eb1..02e56d66 100644 --- a/Source/DataLayer/EntityExtensions.cs +++ b/Source/DataLayer/EntityExtensions.cs @@ -7,8 +7,13 @@ using System.Threading.Tasks; namespace DataLayer { public static class EntityExtensions - { - public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle); + { + public static IEnumerable ByRole(this IEnumerable contributors, Role role) + => contributors + .Where(a => a.Role == role) + .OrderBy(a => a.Order); + + public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle); public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name)); public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name)); diff --git a/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs b/Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.Designer.cs similarity index 96% rename from Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs rename to Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.Designer.cs index f9adfb8b..7147cb51 100644 --- a/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.Designer.cs +++ b/Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.Designer.cs @@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace DataLayer.Migrations { [DbContext(typeof(LibationContext))] - [Migration("20230717220642_AddCategoriesList")] - partial class AddCategoriesList + [Migration("20230718214617_AddCategoryLadder")] + partial class AddCategoryLadder { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -144,14 +144,6 @@ namespace DataLayer.Migrations b.HasIndex("AudibleCategoryId"); b.ToTable("Categories"); - - b.HasData( - new - { - CategoryId = -1, - AudibleCategoryId = "", - Name = "" - }); }); modelBuilder.Entity("DataLayer.CategoryLadder", b => @@ -163,12 +155,6 @@ namespace DataLayer.Migrations b.HasKey("CategoryLadderId"); b.ToTable("CategoryLadders"); - - b.HasData( - new - { - CategoryLadderId = -1 - }); }); modelBuilder.Entity("DataLayer.Contributor", b => diff --git a/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.cs b/Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.cs similarity index 95% rename from Source/DataLayer/Migrations/20230717220642_AddCategoriesList.cs rename to Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.cs index 0121e911..00f2d899 100644 --- a/Source/DataLayer/Migrations/20230717220642_AddCategoriesList.cs +++ b/Source/DataLayer/Migrations/20230718214617_AddCategoryLadder.cs @@ -5,7 +5,7 @@ namespace DataLayer.Migrations { /// - public partial class AddCategoriesList : Migration + public partial class AddCategoryLadder : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -26,6 +26,11 @@ namespace DataLayer.Migrations name: "IX_Books_CategoryId", table: "Books"); + migrationBuilder.DeleteData( + table: "Categories", + keyColumn: "CategoryId", + keyValue: -1); + migrationBuilder.DropColumn( name: "ParentCategoryCategoryId", table: "Categories"); @@ -94,11 +99,6 @@ namespace DataLayer.Migrations onDelete: ReferentialAction.Cascade); }); - migrationBuilder.InsertData( - table: "CategoryLadders", - column: "CategoryLadderId", - value: -1); - migrationBuilder.CreateIndex( name: "IX_BookCategory_BookId", table: "BookCategory", @@ -140,12 +140,10 @@ namespace DataLayer.Migrations nullable: false, defaultValue: 0); - migrationBuilder.UpdateData( + migrationBuilder.InsertData( table: "Categories", - keyColumn: "CategoryId", - keyValue: -1, - column: "ParentCategoryCategoryId", - value: null); + columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" }, + values: new object[] { -1, "", "", null }); migrationBuilder.CreateIndex( name: "IX_Categories_ParentCategoryCategoryId", diff --git a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs index bafd6dfa..e246306f 100644 --- a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs @@ -141,14 +141,6 @@ namespace DataLayer.Migrations b.HasIndex("AudibleCategoryId"); b.ToTable("Categories"); - - b.HasData( - new - { - CategoryId = -1, - AudibleCategoryId = "", - Name = "" - }); }); modelBuilder.Entity("DataLayer.CategoryLadder", b => @@ -160,12 +152,6 @@ namespace DataLayer.Migrations b.HasKey("CategoryLadderId"); b.ToTable("CategoryLadders"); - - b.HasData( - new - { - CategoryLadderId = -1 - }); }); modelBuilder.Entity("DataLayer.Contributor", b => diff --git a/Source/DataLayer/QueryObjects/CategoryQueries.cs b/Source/DataLayer/QueryObjects/CategoryQueries.cs new file mode 100644 index 00000000..9ac733b0 --- /dev/null +++ b/Source/DataLayer/QueryObjects/CategoryQueries.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq; + +namespace DataLayer +{ + public static class CategoryQueries + { + public static IQueryable GetCategoryLadders(this LibationContext context) + => context.CategoryLadders.Include(c => c._categories); + } +} diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index 40dc3fa3..f3813e1c 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -188,13 +188,15 @@ namespace DtoImporterService if (item.CategoryLadders is not null) { + var ladders = new List(); foreach (var ladder in item.CategoryLadders.Select(cl => cl.Ladder).Where(l => l?.Length > 0)) { var categoryIds = ladder.Select(l => l.CategoryId).ToList(); - var cata = categoryImporter.LadderCache.Single(c => c.Equals(categoryIds)); - - book.UpsertCategories(cata); + ladders.Add(categoryImporter.LadderCache.Single(c => c.Equals(categoryIds))); } + //Set all ladders at once so ladders that have been + //removed by audible can be removed from the DB + book.SetCategoryLadders(ladders); } } diff --git a/Source/DtoImporterService/CategoryImporter.cs b/Source/DtoImporterService/CategoryImporter.cs index 7f6c50fd..c8605870 100644 --- a/Source/DtoImporterService/CategoryImporter.cs +++ b/Source/DtoImporterService/CategoryImporter.cs @@ -12,23 +12,15 @@ namespace DtoImporterService { protected override IValidator Validator => new CategoryValidator(); - private Dictionary Cache { get; set; } = new(); + private Dictionary CategoryCache { get; set; } = new(); public HashSet LadderCache { get; private set; } = new(); public CategoryImporter(LibationContext context) : base(context) { } protected override int DoImport(IEnumerable importItems) { - // get distinct - var categoryIds = importItems - .Select(i => i.DtoItem) - .GetCategoriesDistinct() - .Select(c => c.CategoryId) - .Distinct() - .ToList(); - // load db existing => .Local - loadLocal_categories(categoryIds); + loadLocal_categories(); // upsert var categoryLadders = importItems @@ -41,14 +33,11 @@ namespace DtoImporterService return qtyNew; } - private void loadLocal_categories(List categoryIds) + private void loadLocal_categories() { // load existing => local - Cache = DbContext.Categories - .Where(c => categoryIds.Contains(c.AudibleCategoryId)) - .ToDictionarySafe(c => c.AudibleCategoryId); - - LadderCache = DbContext.CategoryLadders.ToHashSet(); + LadderCache = DbContext.GetCategoryLadders().ToHashSet(); + CategoryCache = LadderCache.SelectMany(cl => cl.Categories).ToDictionarySafe(c => c.AudibleCategoryId); } // only use after loading contributors => local @@ -65,10 +54,9 @@ namespace DtoImporterService var id = ladder[i].CategoryId; var name = ladder[i].CategoryName; - if (!Cache.TryGetValue(id, out var category)) + if (!CategoryCache.TryGetValue(id, out var category)) { category = addCategory(id, name); - qtyNew++; } categories.Add(category); @@ -111,7 +99,7 @@ namespace DtoImporterService var entityEntry = DbContext.Categories.Add(category); var entity = entityEntry.Entity; - Cache.Add(entity.AudibleCategoryId, entity); + CategoryCache.Add(entity.AudibleCategoryId, entity); return entity; } catch (Exception ex) From 4e34834c357711cd9a7bb903aa406ce4f07967a4 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Tue, 18 Jul 2023 16:00:06 -0600 Subject: [PATCH 4/6] Fix category search indexing --- Source/DataLayer/EntityExtensions.cs | 10 +++++++++- Source/LibationSearchEngine/SearchEngine.cs | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Source/DataLayer/EntityExtensions.cs b/Source/DataLayer/EntityExtensions.cs index 02e56d66..9bfe1aff 100644 --- a/Source/DataLayer/EntityExtensions.cs +++ b/Source/DataLayer/EntityExtensions.cs @@ -61,7 +61,15 @@ namespace DataLayer .Distinct() .ToArray(); - public static string[] CategoriesIds(this Book book) + public static string[] AllCategoryNames(this Book book) + => book.CategoriesLink?.Any() is not true ? Array.Empty() + : book + .CategoriesLink + .SelectMany(cl => cl.CategoryLadder.Categories) + .Select(c => c.Name) + .ToArray(); + + public static string[] AllCategoryIds(this Book book) => book.CategoriesLink?.Any() is not true ? null : book .CategoriesLink diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs index 1b55d6e3..05e01472 100644 --- a/Source/LibationSearchEngine/SearchEngine.cs +++ b/Source/LibationSearchEngine/SearchEngine.cs @@ -41,7 +41,8 @@ namespace LibationSearchEngine { FieldType.String, lb => lb.Book.Publisher, nameof(Book.Publisher) }, { FieldType.String, lb => lb.Book.SeriesNames(), "SeriesNames", "Narrator", "Series" }, { FieldType.String, lb => string.Join(", ", lb.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId)), "SeriesId" }, - { FieldType.String, lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()), "Category", "Categories", "CategoriesId", "CategoryId", "CategoriesNames" }, + { FieldType.String, lb => lb.Book.AllCategoryIds() is null ? null : string.Join(", ", lb.Book.AllCategoryIds()), "CategoriesId", "CategoryId" }, + { FieldType.String, lb => lb.Book.AllCategoryNames() is null ? null : string.Join(", ", lb.Book.AllCategoryNames()), "Category", "Categories", "CategoriesNames" }, { FieldType.String, lb => lb.Book.UserDefinedItem.Tags, TAGS.FirstCharToUpper() }, { FieldType.String, lb => lb.Book.Locale, "Locale", "Region" }, { FieldType.String, lb => lb.Account, "Account", "Email" }, From b94f9bbc152c611f4cfbfa8a677616f7d5579991 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Tue, 18 Jul 2023 16:11:22 -0600 Subject: [PATCH 5/6] Fix grid update bug --- Source/LibationWinForms/GridView/ProductsGrid.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 2d7f653c..790f5ba0 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -362,12 +362,17 @@ namespace LibationWinForms.GridView seriesEntry.UpdateLibraryBook(seriesBook); } + //Series entry must be expanded so its child can + //be placed in the correct position beneath it. + var isExpanded = seriesEntry.Liberate.Expanded; + bindingList.ExpandItem(seriesEntry); + //Add episode to the grid beneath the parent int seriesIndex = bindingList.IndexOf(seriesEntry); int episodeIndex = seriesEntry.Children.IndexOf(episodeEntry); bindingList.Insert(seriesIndex + 1 + episodeIndex, episodeEntry); - if (seriesEntry.Liberate.Expanded) + if (isExpanded) bindingList.ExpandItem(seriesEntry); else bindingList.CollapseItem(seriesEntry); From 914e574bf8f1a5421ef4045df3401a452055aca2 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Tue, 18 Jul 2023 16:18:01 -0600 Subject: [PATCH 6/6] Improve GridView - Remove LongDescription - Description has the full description - Better MyRating updating --- Source/LibationAvalonia/Views/ProductsDisplay.axaml | 2 +- .../LibationAvalonia/Views/ProductsDisplay.axaml.cs | 2 +- Source/LibationUiBase/GridView/GridEntry[TStatus].cs | 12 ++++-------- Source/LibationUiBase/GridView/IGridEntry.cs | 1 - Source/LibationWinForms/GridView/ProductsDisplay.cs | 2 +- 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml b/Source/LibationAvalonia/Views/ProductsDisplay.axaml index 640391e4..4c7e8804 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml @@ -148,7 +148,7 @@ - + diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 00a3eb95..4ba3516d 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -536,7 +536,7 @@ namespace LibationAvalonia.Views var displayWindow = new DescriptionDisplayDialog { SpawnLocation = new Point(pt.X, pt.Y), - DescriptionText = gEntry.LongDescription, + DescriptionText = gEntry.Description, }; void CloseWindow(object o, DataGridRowEventArgs e) diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs index 9166c326..11e845f5 100644 --- a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs @@ -27,7 +27,6 @@ namespace LibationUiBase.GridView [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } [Browsable(false)] public float SeriesIndex { get; protected set; } - [Browsable(false)] public string LongDescription { get; protected set; } [Browsable(false)] public abstract DateTime DateAdded { get; } [Browsable(false)] public Book Book => LibraryBook.Book; @@ -109,9 +108,7 @@ namespace LibationUiBase.GridView Series = Book.SeriesNames(includeIndex: true); SeriesOrder = new SeriesOrder(Book.SeriesLink); Length = GetBookLengthString(); - //Ratings are changed using Update(), which is a problem for Avalonia data bindings because - //the reference doesn't change. Clone the rating so that it updates within Avalonia properly. - _myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating); + RaiseAndSetIfChanged(ref _myRating, Book.UserDefinedItem.Rating, nameof(MyRating)); PurchaseDate = GetPurchaseDateString(); ProductRating = Book.Rating ?? new Rating(0, 0, 0); Authors = Book.AuthorNames(); @@ -119,13 +116,10 @@ namespace LibationUiBase.GridView Category = string.Join(", ", Book.LowestCategoryNames()); Misc = GetMiscDisplay(libraryBook); LastDownload = new(Book.UserDefinedItem); - LongDescription = GetDescriptionDisplay(Book); - Description = LongDescription;// TrimTextToWord(LongDescription, 62); + Description = GetDescriptionDisplay(Book); SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; BookTags = GetBookTags(); - RaisePropertyChanged(nameof(MyRating)); - UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; } @@ -182,6 +176,8 @@ namespace LibationUiBase.GridView break; case nameof(udi.Rating): _myRating = udi.Rating; + //Ratings are changed using Update(), which is a problem for Avalonia data bindings because + //the reference doesn't change. Must call RaisePropertyChanged instead of RaiseAndSetIfChanged RaisePropertyChanged(nameof(MyRating)); break; } diff --git a/Source/LibationUiBase/GridView/IGridEntry.cs b/Source/LibationUiBase/GridView/IGridEntry.cs index f9aaacb5..ed3ebb9a 100644 --- a/Source/LibationUiBase/GridView/IGridEntry.cs +++ b/Source/LibationUiBase/GridView/IGridEntry.cs @@ -10,7 +10,6 @@ namespace LibationUiBase.GridView EntryStatus Liberate { get; } float SeriesIndex { get; } string AudibleProductId { get; } - string LongDescription { get; } LibraryBook LibraryBook { get; } Book Book { get; } DateTime DateAdded { get; } diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index dc1460c7..9b02d38d 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -75,7 +75,7 @@ namespace LibationWinForms.GridView var displayWindow = new DescriptionDisplay { SpawnLocation = PointToScreen(cellRectangle.Location + new Size(cellRectangle.Width, 0)), - DescriptionText = liveGridEntry.LongDescription, + DescriptionText = liveGridEntry.Description, BorderThickness = 2, };