From ea6adeb58f666d209c2e8dabd0656857871a030e Mon Sep 17 00:00:00 2001 From: Mbucari Date: Mon, 17 Jul 2023 16:50:45 -0600 Subject: [PATCH] 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}