From fd6ee42f5fb467839e957c8b6770a64c370043b8 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Tue, 14 Mar 2023 08:45:22 -0500 Subject: [PATCH] Reading List Polish (#1879) * Use Reading Order to count epub pages rather than raw HTML files. * Send email on background thread for initial invite flow. * Reorder default writing style for new users so Horizontal is default * Changed reading activity to use average hours read rather than events to bring more meaningful data. * added ability to start reading incognito from the top of series detail, needs a bit of styling help though. * Refactored extensions out into their own package, added new fields for reading list to cover total run, cbl import now takes those dates and overrides on import. Replaced many instances of numbers to be comma separated. * Added ability to edit reading list run start and end year/month. Refactored some code for valid month/year into a helper method. * Added a way to see the reading list's release years. * Added some merged image code, but had to remove due to cover dimensions not fixed. * tweaked style for accessibility mode on reading list items * Tweaked css for non virtualized and virtualized containers * Fixed release updates failing * Commented out the merge code. * Typo on words read per year * Fixed unit tests * Fixed virtualized scroll * Cleanup CSS --- .../Extensions/QueryableExtensionsTests.cs | 1 + API/Controllers/AccountController.cs | 20 +- API/Controllers/ImageController.cs | 6 +- API/DTOs/ReadingLists/CBL/CblReadingList.cs | 10 +- API/DTOs/ReadingLists/ReadingListDto.cs | 20 +- API/DTOs/ReadingLists/UpdateReadingListDto.cs | 5 + API/Data/DataContext.cs | 5 +- ...313125914_ReadingListDateRange.Designer.cs | 1872 +++++++++++++++++ .../20230313125914_ReadingListDateRange.cs | 80 + .../Migrations/DataContextModelSnapshot.cs | 16 +- API/Data/Repositories/ChapterRepository.cs | 1 + .../Repositories/CollectionTagRepository.cs | 1 + API/Data/Repositories/GenreRepository.cs | 1 + API/Data/Repositories/LibraryRepository.cs | 1 + API/Data/Repositories/PersonRepository.cs | 1 + .../Repositories/ReadingListRepository.cs | 45 +- API/Data/Repositories/SeriesRepository.cs | 1 + API/Data/Repositories/TagRepository.cs | 1 + API/Data/Repositories/UserRepository.cs | 1 + API/Entities/Enums/WritingStyle.cs | 12 +- API/Entities/ReadingList.cs | 24 +- API/Extensions/ChapterListExtensions.cs | 3 +- .../QueryExtensions/IncludesExtensions.cs | 148 ++ .../QueryExtensions/QueryableExtensions.cs | 92 + .../RestrictByAgeExtensions.cs | 96 + API/Extensions/QueryableExtensions.cs | 290 --- API/Helpers/NumberHelper.cs | 7 + API/Services/BookService.cs | 4 +- API/Services/ImageService.cs | 20 + API/Services/ReadingListService.cs | 81 +- API/Services/SeriesService.cs | 2 +- API/Services/StatisticService.cs | 22 +- API/Services/Tasks/VersionUpdaterService.cs | 2 +- .../app/_models/preferences/writing-style.ts | 4 +- UI/Web/src/app/_models/reading-list.ts | 4 + .../all-series/all-series.component.html | 2 +- .../bookmarks/bookmarks.component.html | 2 +- .../bulk-operations.component.html | 2 +- .../card-detail-layout.component.scss | 2 +- .../all-collections.component.html | 2 +- .../library-detail.component.html | 2 +- .../nav-header/nav-header.component.html | 11 +- .../draggable-ordered-list.component.html | 81 +- .../draggable-ordered-list.component.scss | 14 +- .../draggable-ordered-list.component.ts | 12 +- .../reading-list-detail.component.html | 48 +- .../reading-list-detail.component.scss | 22 + .../reading-list-item.component.html | 4 +- .../reading-lists.component.html | 2 +- .../edit-reading-list-modal.component.html | 54 + .../edit-reading-list-modal.component.ts | 8 + .../app/reading-list/reading-list.module.ts | 2 + .../series-detail.component.html | 28 +- .../series-detail.component.scss | 7 + .../series-detail/series-detail.component.ts | 6 +- .../app/series-detail/series-detail.module.ts | 3 +- .../reading-activity.component.html | 2 +- .../user-stats-info-cards.component.ts | 2 +- .../want-to-read/want-to-read.component.html | 2 +- openapi.json | 58 +- 60 files changed, 2847 insertions(+), 430 deletions(-) create mode 100644 API/Data/Migrations/20230313125914_ReadingListDateRange.Designer.cs create mode 100644 API/Data/Migrations/20230313125914_ReadingListDateRange.cs create mode 100644 API/Extensions/QueryExtensions/IncludesExtensions.cs create mode 100644 API/Extensions/QueryExtensions/QueryableExtensions.cs create mode 100644 API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs delete mode 100644 API/Extensions/QueryableExtensions.cs create mode 100644 API/Helpers/NumberHelper.cs diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index ded191396..6f51b9abd 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -6,6 +6,7 @@ using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; using API.Extensions; +using API.Extensions.QueryExtensions; using API.Helpers.Builders; using Xunit; diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 65ceaa5a8..5a6bf520e 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -16,6 +16,7 @@ using API.Extensions; using API.Services; using API.SignalR; using AutoMapper; +using Hangfire; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Authorization; @@ -605,19 +606,14 @@ public class AccountController : BaseApiController var accessible = await _accountService.CheckIfAccessible(Request); if (accessible) { - try + // Do the email send on a background thread to ensure UI can move forward without having to wait for a timeout when users use fake emails + BackgroundJob.Enqueue(() => _emailService.SendConfirmationEmail(new ConfirmationEmailDto() { - await _emailService.SendConfirmationEmail(new ConfirmationEmailDto() - { - EmailAddress = dto.Email, - InvitingUser = adminUser.UserName!, - ServerConfirmationLink = emailLink - }); - } - catch (Exception) - { - /* Swallow exception */ - } + EmailAddress = dto.Email, + InvitingUser = adminUser.UserName!, + ServerConfirmationLink = emailLink + })); + } return Ok(new InviteUserResponse diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 03fbc335e..29f827dfb 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; @@ -118,7 +119,10 @@ public class ImageController : BaseApiController public async Task GetReadingListCoverImage(int readingListId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) + { + return BadRequest($"No cover image"); + } var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", string.Empty); return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); diff --git a/API/DTOs/ReadingLists/CBL/CblReadingList.cs b/API/DTOs/ReadingLists/CBL/CblReadingList.cs index b15b2a532..001e6434b 100644 --- a/API/DTOs/ReadingLists/CBL/CblReadingList.cs +++ b/API/DTOs/ReadingLists/CBL/CblReadingList.cs @@ -33,28 +33,28 @@ public class CblReadingList /// /// This is not a standard, adding based on discussion with CBL Maintainers [XmlElement(ElementName="StartYear")] - public int StartYear { get; set; } + public int StartYear { get; set; } = -1; /// /// Start Year of the Reading List. Overrides calculation /// /// This is not a standard, adding based on discussion with CBL Maintainers - [XmlElement(ElementName="StartMonth")] - public int StartMonth { get; set; } + [XmlElement(ElementName = "StartMonth")] + public int StartMonth { get; set; } = -1; /// /// End Year of the Reading List. Overrides calculation /// /// This is not a standard, adding based on discussion with CBL Maintainers [XmlElement(ElementName="EndYear")] - public int EndYear { get; set; } + public int EndYear { get; set; } = -1; /// /// End Year of the Reading List. Overrides calculation /// /// This is not a standard, adding based on discussion with CBL Maintainers [XmlElement(ElementName="EndMonth")] - public int EndMonth { get; set; } + public int EndMonth { get; set; } = -1; /// /// Issues of the Reading List diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index b1ceb10ed..f8791b0d6 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs.ReadingLists; +using System; + +namespace API.DTOs.ReadingLists; public class ReadingListDto { @@ -14,5 +16,21 @@ public class ReadingListDto /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. /// public string CoverImage { get; set; } = string.Empty; + /// + /// Minimum Year the Reading List starts + /// + public int StartingYear { get; set; } + /// + /// Minimum Month the Reading List starts + /// + public int StartingMonth { get; set; } + /// + /// Maximum Year the Reading List starts + /// + public int EndingYear { get; set; } + /// + /// Maximum Month the Reading List starts + /// + public int EndingMonth { get; set; } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListDto.cs b/API/DTOs/ReadingLists/UpdateReadingListDto.cs index ca93947c2..6b590707a 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListDto.cs @@ -10,4 +10,9 @@ public class UpdateReadingListDto public string Summary { get; set; } = string.Empty; public bool Promoted { get; set; } public bool CoverImageLocked { get; set; } + public int StartingMonth { get; set; } = 0; + public int StartingYear { get; set; } = 0; + public int EndingMonth { get; set; } = 0; + public int EndingYear { get; set; } = 0; + } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index e44da6913..1e79d57d2 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using API.Entities; +using API.Entities.Enums; using API.Entities.Enums.UserPreferences; using API.Entities.Interfaces; using API.Entities.Metadata; @@ -86,10 +87,12 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.BackgroundColor) .HasDefaultValue("#000000"); - builder.Entity() .Property(b => b.GlobalPageLayoutMode) .HasDefaultValue(PageLayoutMode.Cards); + builder.Entity() + .Property(b => b.BookReaderWritingStyle) + .HasDefaultValue(WritingStyle.Horizontal); builder.Entity() diff --git a/API/Data/Migrations/20230313125914_ReadingListDateRange.Designer.cs b/API/Data/Migrations/20230313125914_ReadingListDateRange.Designer.cs new file mode 100644 index 000000000..3500a3080 --- /dev/null +++ b/API/Data/Migrations/20230313125914_ReadingListDateRange.Designer.cs @@ -0,0 +1,1872 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20230313125914_ReadingListDateRange")] + partial class ReadingListDateRange + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230313125914_ReadingListDateRange.cs b/API/Data/Migrations/20230313125914_ReadingListDateRange.cs new file mode 100644 index 000000000..e4de75aa2 --- /dev/null +++ b/API/Data/Migrations/20230313125914_ReadingListDateRange.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ReadingListDateRange : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EndingMonth", + table: "ReadingList", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "EndingYear", + table: "ReadingList", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StartingMonth", + table: "ReadingList", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "StartingYear", + table: "ReadingList", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AlterColumn( + name: "BookReaderWritingStyle", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EndingMonth", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "EndingYear", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "StartingMonth", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "StartingYear", + table: "ReadingList"); + + migrationBuilder.AlterColumn( + name: "BookReaderWritingStyle", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER", + oldDefaultValue: 0); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 0bd51c92a..a06bf1979 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -225,7 +225,9 @@ namespace API.Data.Migrations .HasColumnType("INTEGER"); b.Property("BookReaderWritingStyle") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); b.Property("BookThemeName") .ValueGeneratedOnAdd() @@ -871,6 +873,12 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + b.Property("LastModified") .HasColumnType("TEXT"); @@ -883,6 +891,12 @@ namespace API.Data.Migrations b.Property("Promoted") .HasColumnType("INTEGER"); + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + b.Property("Summary") .HasColumnType("TEXT"); diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 81905f04d..f10f036fc 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -7,6 +7,7 @@ using API.DTOs.Metadata; using API.DTOs.Reader; using API.Entities; using API.Extensions; +using API.Extensions.QueryExtensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index 86ba4cc7a..dd9d375b4 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -6,6 +6,7 @@ using API.Data.Misc; using API.DTOs.CollectionTags; using API.Entities; using API.Extensions; +using API.Extensions.QueryExtensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index 14e575ef6..b552093e7 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using API.DTOs.Metadata; using API.Entities; using API.Extensions; +using API.Extensions.QueryExtensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 02081b25e..c8be8929d 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -10,6 +10,7 @@ using API.DTOs.Metadata; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Extensions.QueryExtensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Kavita.Common.Extensions; diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 7eea282a7..0e05a6672 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using API.DTOs; using API.Entities; using API.Extensions; +using API.Extensions.QueryExtensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 3b27caa8f..12f98e565 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; @@ -6,6 +7,7 @@ using API.DTOs.ReadingLists; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Extensions.QueryExtensions; using API.Helpers; using API.Services; using AutoMapper; @@ -15,10 +17,18 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +[Flags] +public enum ReadingListIncludes +{ + None = 1, + Items = 2, + ItemChapter = 4, +} + public interface IReadingListRepository { Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams); - Task GetReadingListByIdAsync(int readingListId); + Task GetReadingListByIdAsync(int readingListId, ReadingListIncludes includes = ReadingListIncludes.None); Task> GetReadingListItemDtosByIdAsync(int readingListId, int userId); Task GetReadingListDtoByIdAsync(int readingListId, int userId); Task> AddReadingProgressModifiers(int userId, IList items); @@ -34,9 +44,9 @@ public interface IReadingListRepository Task GetCoverImageAsync(int readingListId); Task> GetAllCoverImagesAsync(); Task ReadingListExists(string name); - Task> GetAllReadingListsAsync(); IEnumerable GetReadingListCharactersAsync(int readingListId); Task> GetAllWithNonWebPCovers(); + Task> GetFirstFourCoverImagesByReadingListId(int readingListId); } public class ReadingListRepository : IReadingListRepository @@ -88,15 +98,6 @@ public class ReadingListRepository : IReadingListRepository .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); } - public async Task> GetAllReadingListsAsync() - { - return await _context.ReadingList - .Include(r => r.Items.OrderBy(i => i.Order)) - .AsSplitQuery() - .OrderBy(l => l.Title) - .ToListAsync(); - } - public IEnumerable GetReadingListCharactersAsync(int readingListId) { return _context.ReadingListItem @@ -114,6 +115,23 @@ public class ReadingListRepository : IReadingListRepository .ToListAsync(); } + /// + /// If less than 4 images exist, will return nothing back. Will not be full paths, but just cover image filenames + /// + /// + /// + /// + public async Task> GetFirstFourCoverImagesByReadingListId(int readingListId) + { + return await _context.ReadingListItem + .Where(ri => ri.ReadingListId == readingListId) + .Include(ri => ri.Chapter) + .Where(ri => ri.Chapter.CoverImage != null) + .Select(ri => ri.Chapter.CoverImage) + .Take(4) + .ToListAsync(); + } + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); @@ -151,10 +169,11 @@ public class ReadingListRepository : IReadingListRepository return await query.ToListAsync(); } - public async Task GetReadingListByIdAsync(int readingListId) + public async Task GetReadingListByIdAsync(int readingListId, ReadingListIncludes includes = ReadingListIncludes.None) { return await _context.ReadingList .Where(r => r.Id == readingListId) + .Includes(includes) .Include(r => r.Items.OrderBy(item => item.Order)) .AsSplitQuery() .SingleOrDefaultAsync(); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index de7c5c39f..b0c793b64 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -17,6 +17,7 @@ using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; using API.Extensions; +using API.Extensions.QueryExtensions; using API.Helpers; using API.Services; using API.Services.Tasks; diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index 07f895193..c8d58a2bf 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using API.DTOs.Metadata; using API.Entities; using API.Extensions; +using API.Extensions.QueryExtensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index f50196c18..681bdbc81 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -9,6 +9,7 @@ using API.DTOs.Filtering; using API.DTOs.Reader; using API.Entities; using API.Extensions; +using API.Extensions.QueryExtensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Identity; diff --git a/API/Entities/Enums/WritingStyle.cs b/API/Entities/Enums/WritingStyle.cs index b2e086599..37d50c160 100644 --- a/API/Entities/Enums/WritingStyle.cs +++ b/API/Entities/Enums/WritingStyle.cs @@ -7,14 +7,14 @@ namespace API.Entities.Enums; /// public enum WritingStyle { - /// - /// Vertical writing style for the book-reader - /// - [Description ("Vertical")] - Vertical = 0, /// /// Horizontal writing style for the book-reader /// [Description ("Horizontal")] - Horizontal = 1 + Horizontal = 0, + /// + /// Vertical writing style for the book-reader + /// + [Description ("Vertical")] + Vertical = 1 } diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs index 94e67963c..d169731bf 100644 --- a/API/Entities/ReadingList.cs +++ b/API/Entities/ReadingList.cs @@ -39,14 +39,22 @@ public class ReadingList : IEntityDate public DateTime LastModified { get; set; } public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } - // /// - // /// Minimum Year and Month the Reading List starts - // /// - // public DateOnly StartingYear { get; set; } - // /// - // /// Maximum Year and Month the Reading List starts - // /// - // public DateOnly EndingYear { get; set; } + /// + /// Minimum Year the Reading List starts + /// + public int StartingYear { get; set; } + /// + /// Minimum Month the Reading List starts + /// + public int StartingMonth { get; set; } + /// + /// Maximum Year the Reading List starts + /// + public int EndingYear { get; set; } + /// + /// Maximum Month the Reading List starts + /// + public int EndingMonth { get; set; } // Relationships public int AppUserId { get; set; } diff --git a/API/Extensions/ChapterListExtensions.cs b/API/Extensions/ChapterListExtensions.cs index 7e24a3981..3c81563a4 100644 --- a/API/Extensions/ChapterListExtensions.cs +++ b/API/Extensions/ChapterListExtensions.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using API.Entities; +using API.Helpers; using API.Parser; namespace API.Extensions; @@ -39,6 +40,6 @@ public static class ChapterListExtensions /// public static int MinimumReleaseYear(this IList chapters) { - return chapters.Select(v => v.ReleaseDate.Year).Where(y => y >= 1000).DefaultIfEmpty().Min(); + return chapters.Select(v => v.ReleaseDate.Year).Where(y => NumberHelper.IsValidYear(y)).DefaultIfEmpty().Min(); } } diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs new file mode 100644 index 000000000..143b4257b --- /dev/null +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -0,0 +1,148 @@ +using System.Linq; +using API.Data.Repositories; +using API.Entities; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions; + +/// +/// All extensions against IQueryable that enables the dynamic including based on bitwise flag pattern +/// +public static class IncludesExtensions +{ + public static IQueryable Includes(this IQueryable queryable, + CollectionTagIncludes includes) + { + if (includes.HasFlag(CollectionTagIncludes.SeriesMetadata)) + { + queryable = queryable.Include(c => c.SeriesMetadatas); + } + + return queryable.AsSplitQuery(); + } + + public static IQueryable Includes(this IQueryable queryable, + ChapterIncludes includes) + { + if (includes.HasFlag(ChapterIncludes.Volumes)) + { + queryable = queryable.Include(v => v.Volume); + } + + if (includes.HasFlag(ChapterIncludes.Files)) + { + queryable = queryable + .Include(c => c.Files); + } + + + return queryable.AsSplitQuery(); + } + + public static IQueryable Includes(this IQueryable query, + SeriesIncludes includeFlags) + { + if (includeFlags.HasFlag(SeriesIncludes.Library)) + { + query = query.Include(u => u.Library); + } + + if (includeFlags.HasFlag(SeriesIncludes.Volumes)) + { + query = query.Include(s => s.Volumes); + } + + if (includeFlags.HasFlag(SeriesIncludes.Chapters)) + { + query = query + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters); + } + + if (includeFlags.HasFlag(SeriesIncludes.Related)) + { + query = query.Include(s => s.Relations) + .ThenInclude(r => r.TargetSeries) + .Include(s => s.RelationOf); + } + + if (includeFlags.HasFlag(SeriesIncludes.Metadata)) + { + query = query.Include(s => s.Metadata) + .ThenInclude(m => m.CollectionTags.OrderBy(g => g.NormalizedTitle)) + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres.OrderBy(g => g.NormalizedTitle)) + .Include(s => s.Metadata) + .ThenInclude(m => m.People) + .Include(s => s.Metadata) + .ThenInclude(m => m.Tags.OrderBy(g => g.NormalizedTitle)); + } + + + return query.AsSplitQuery(); + } + + public static IQueryable Includes(this IQueryable query, AppUserIncludes includeFlags) + { + if (includeFlags.HasFlag(AppUserIncludes.Bookmarks)) + { + query = query.Include(u => u.Bookmarks); + } + + if (includeFlags.HasFlag(AppUserIncludes.Progress)) + { + query = query.Include(u => u.Progresses); + } + + if (includeFlags.HasFlag(AppUserIncludes.ReadingLists)) + { + query = query.Include(u => u.ReadingLists); + } + + if (includeFlags.HasFlag(AppUserIncludes.ReadingListsWithItems)) + { + query = query.Include(u => u.ReadingLists) + .ThenInclude(r => r.Items); + } + + if (includeFlags.HasFlag(AppUserIncludes.Ratings)) + { + query = query.Include(u => u.Ratings); + } + + if (includeFlags.HasFlag(AppUserIncludes.UserPreferences)) + { + query = query.Include(u => u.UserPreferences); + } + + if (includeFlags.HasFlag(AppUserIncludes.WantToRead)) + { + query = query.Include(u => u.WantToRead); + } + + if (includeFlags.HasFlag(AppUserIncludes.Devices)) + { + query = query.Include(u => u.Devices); + } + + return query.AsSplitQuery(); + } + + public static IQueryable Includes(this IQueryable queryable, + ReadingListIncludes includes) + { + if (includes.HasFlag(ReadingListIncludes.Items)) + { + queryable = queryable.Include(r => r.Items.OrderBy(item => item.Order)); + } + + if (includes.HasFlag(ReadingListIncludes.ItemChapter)) + { + queryable = queryable + .Include(r => r.Items.OrderBy(item => item.Order)) + .ThenInclude(ri => ri.Chapter); + } + + return queryable.AsSplitQuery(); + } +} diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs new file mode 100644 index 000000000..57a5e844a --- /dev/null +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using API.Data.Misc; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions; + +public static class QueryableExtensions +{ + public static Task GetUserAgeRestriction(this DbSet queryable, int userId) + { + if (userId < 1) + { + return Task.FromResult(new AgeRestriction() + { + AgeRating = AgeRating.NotApplicable, + IncludeUnknowns = true + }); + } + return queryable + .AsNoTracking() + .Where(u => u.Id == userId) + .Select(u => + new AgeRestriction(){ + AgeRating = u.AgeRestriction, + IncludeUnknowns = u.AgeRestrictionIncludeUnknowns + }) + .SingleAsync(); + } + + + /// + /// Applies restriction based on if the Library has restrictions (like include in search) + /// + /// + /// + /// + public static IQueryable IsRestricted(this IQueryable query, QueryContext context) + { + if (context.HasFlag(QueryContext.None)) return query; + + if (context.HasFlag(QueryContext.Dashboard)) + { + query = query.Where(l => l.IncludeInDashboard); + } + + if (context.HasFlag(QueryContext.Recommended)) + { + query = query.Where(l => l.IncludeInRecommended); + } + + if (context.HasFlag(QueryContext.Search)) + { + query = query.Where(l => l.IncludeInSearch); + } + + return query; + } + + /// + /// Returns all libraries for a given user + /// + /// + /// + /// + /// + public static IQueryable GetUserLibraries(this IQueryable library, int userId, QueryContext queryContext = QueryContext.None) + { + return library + .Include(l => l.AppUsers) + .Where(lib => lib.AppUsers.Any(user => user.Id == userId)) + .IsRestricted(queryContext) + .AsNoTracking() + .AsSplitQuery() + .Select(lib => lib.Id); + } + + public static IEnumerable Range(this DateTime startDate, int numberOfDays) => + Enumerable.Range(0, numberOfDays).Select(e => startDate.AddDays(e)); + + public static IQueryable WhereIf(this IQueryable queryable, bool condition, + Expression> predicate) + { + return condition ? queryable.Where(predicate) : queryable; + } +} diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs new file mode 100644 index 000000000..08f0922b0 --- /dev/null +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -0,0 +1,96 @@ +using System.Linq; +using API.Data.Misc; +using API.Entities; +using API.Entities.Enums; + +namespace API.Extensions.QueryExtensions; + +/// +/// Responsible for restricting Entities based on an AgeRestriction +/// +public static class RestrictByAgeExtensions +{ + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(s => s.Metadata.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.Metadata.AgeRating != AgeRating.Unknown); + } + + //q.WhereIf(!restriction.IncludeUnknowns, s => s.Metadata.AgeRating != AgeRating.Unknown); + + return q; + } + + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + + if (restriction.IncludeUnknowns) + { + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating)); + } + + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + } + + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + + if (restriction.IncludeUnknowns) + { + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating)); + } + + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + } + + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + + if (restriction.IncludeUnknowns) + { + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating)); + } + + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + } + + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + + if (restriction.IncludeUnknowns) + { + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating)); + } + + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + } + + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(rl => rl.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(rl => rl.AgeRating != AgeRating.Unknown); + } + + return q; + } +} diff --git a/API/Extensions/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs deleted file mode 100644 index 6b8de06dd..000000000 --- a/API/Extensions/QueryableExtensions.cs +++ /dev/null @@ -1,290 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Threading.Tasks; -using API.Data.Misc; -using API.Data.Repositories; -using API.Entities; -using API.Entities.Enums; -using Microsoft.EntityFrameworkCore; - -namespace API.Extensions; - -public static class QueryableExtensions -{ - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) - { - if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; - var q = queryable.Where(s => s.Metadata.AgeRating <= restriction.AgeRating); - if (!restriction.IncludeUnknowns) - { - return q.Where(s => s.Metadata.AgeRating != AgeRating.Unknown); - } - - return q; - } - - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) - { - if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; - - if (restriction.IncludeUnknowns) - { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); - } - - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); - } - - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) - { - if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; - - if (restriction.IncludeUnknowns) - { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); - } - - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); - } - - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) - { - if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; - - if (restriction.IncludeUnknowns) - { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); - } - - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); - } - - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) - { - if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; - - if (restriction.IncludeUnknowns) - { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); - } - - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); - } - - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) - { - if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; - var q = queryable.Where(rl => rl.AgeRating <= restriction.AgeRating); - - if (!restriction.IncludeUnknowns) - { - return q.Where(rl => rl.AgeRating != AgeRating.Unknown); - } - - return q; - } - - public static Task GetUserAgeRestriction(this DbSet queryable, int userId) - { - if (userId < 1) - { - return Task.FromResult(new AgeRestriction() - { - AgeRating = AgeRating.NotApplicable, - IncludeUnknowns = true - }); - } - return queryable - .AsNoTracking() - .Where(u => u.Id == userId) - .Select(u => - new AgeRestriction(){ - AgeRating = u.AgeRestriction, - IncludeUnknowns = u.AgeRestrictionIncludeUnknowns - }) - .SingleAsync(); - } - - public static IQueryable Includes(this IQueryable queryable, - CollectionTagIncludes includes) - { - if (includes.HasFlag(CollectionTagIncludes.SeriesMetadata)) - { - queryable = queryable.Include(c => c.SeriesMetadatas); - } - - return queryable.AsSplitQuery(); - } - - public static IQueryable Includes(this IQueryable queryable, - ChapterIncludes includes) - { - if (includes.HasFlag(ChapterIncludes.Volumes)) - { - queryable = queryable.Include(v => v.Volume); - } - - if (includes.HasFlag(ChapterIncludes.Files)) - { - queryable = queryable - .Include(c => c.Files); - } - - - return queryable.AsSplitQuery(); - } - - public static IQueryable Includes(this IQueryable query, - SeriesIncludes includeFlags) - { - if (includeFlags.HasFlag(SeriesIncludes.Library)) - { - query = query.Include(u => u.Library); - } - - if (includeFlags.HasFlag(SeriesIncludes.Volumes)) - { - query = query.Include(s => s.Volumes); - } - - if (includeFlags.HasFlag(SeriesIncludes.Chapters)) - { - query = query - .Include(s => s.Volumes) - .ThenInclude(v => v.Chapters); - } - - if (includeFlags.HasFlag(SeriesIncludes.Related)) - { - query = query.Include(s => s.Relations) - .ThenInclude(r => r.TargetSeries) - .Include(s => s.RelationOf); - } - - if (includeFlags.HasFlag(SeriesIncludes.Metadata)) - { - query = query.Include(s => s.Metadata) - .ThenInclude(m => m.CollectionTags.OrderBy(g => g.NormalizedTitle)) - .Include(s => s.Metadata) - .ThenInclude(m => m.Genres.OrderBy(g => g.NormalizedTitle)) - .Include(s => s.Metadata) - .ThenInclude(m => m.People) - .Include(s => s.Metadata) - .ThenInclude(m => m.Tags.OrderBy(g => g.NormalizedTitle)); - } - - - return query.AsSplitQuery(); - } - - public static IQueryable Includes(this IQueryable query, AppUserIncludes includeFlags) - { - if (includeFlags.HasFlag(AppUserIncludes.Bookmarks)) - { - query = query.Include(u => u.Bookmarks); - } - - if (includeFlags.HasFlag(AppUserIncludes.Progress)) - { - query = query.Include(u => u.Progresses); - } - - if (includeFlags.HasFlag(AppUserIncludes.ReadingLists)) - { - query = query.Include(u => u.ReadingLists); - } - - if (includeFlags.HasFlag(AppUserIncludes.ReadingListsWithItems)) - { - query = query.Include(u => u.ReadingLists) - .ThenInclude(r => r.Items); - } - - if (includeFlags.HasFlag(AppUserIncludes.Ratings)) - { - query = query.Include(u => u.Ratings); - } - - if (includeFlags.HasFlag(AppUserIncludes.UserPreferences)) - { - query = query.Include(u => u.UserPreferences); - } - - if (includeFlags.HasFlag(AppUserIncludes.WantToRead)) - { - query = query.Include(u => u.WantToRead); - } - - if (includeFlags.HasFlag(AppUserIncludes.Devices)) - { - query = query.Include(u => u.Devices); - } - - return query.AsSplitQuery(); - } - - /// - /// Applies restriction based on if the Library has restrictions (like include in search) - /// - /// - /// - /// - public static IQueryable IsRestricted(this IQueryable query, QueryContext context) - { - if (context.HasFlag(QueryContext.None)) return query; - - if (context.HasFlag(QueryContext.Dashboard)) - { - query = query.Where(l => l.IncludeInDashboard); - } - - if (context.HasFlag(QueryContext.Recommended)) - { - query = query.Where(l => l.IncludeInRecommended); - } - - if (context.HasFlag(QueryContext.Search)) - { - query = query.Where(l => l.IncludeInSearch); - } - - return query; - } - - /// - /// Returns all libraries for a given user - /// - /// - /// - /// - /// - public static IQueryable GetUserLibraries(this IQueryable library, int userId, QueryContext queryContext = QueryContext.None) - { - return library - .Include(l => l.AppUsers) - .Where(lib => lib.AppUsers.Any(user => user.Id == userId)) - .IsRestricted(queryContext) - .AsNoTracking() - .AsSplitQuery() - .Select(lib => lib.Id); - } - - public static IEnumerable Range(this DateTime startDate, int numberOfDays) => - Enumerable.Range(0, numberOfDays).Select(e => startDate.AddDays(e)); - - public static IQueryable WhereIf(this IQueryable queryable, bool condition, - Expression> predicate) - { - return condition ? queryable.Where(predicate) : queryable; - } -} diff --git a/API/Helpers/NumberHelper.cs b/API/Helpers/NumberHelper.cs new file mode 100644 index 000000000..b15f7e680 --- /dev/null +++ b/API/Helpers/NumberHelper.cs @@ -0,0 +1,7 @@ +namespace API.Helpers; + +public static class NumberHelper +{ + public static bool IsValidMonth(int number) => number is > 0 and <= 12; + public static bool IsValidYear(int number) => number is >= 1000; +} diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index cdccf10ab..16d72b4ee 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -508,7 +508,7 @@ public class BookService : IBookService return null; } - + private static (int year, int month, int day) GetPublicationDate(string publicationDate) { var dateParsed = DateTime.TryParse(publicationDate, out var date); @@ -571,7 +571,7 @@ public class BookService : IBookService } using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); - return epubBook.Content.Html.Count; + return epubBook.GetReadingOrder().Count; } catch (Exception ex) { diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 198b16763..9f477d36e 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -218,4 +219,23 @@ public class ImageService : IImageService } + public static string CreateMergedImage(List coverImages, string dest) + { + // TODO: Needs testing + // Currently this doesn't work due to non-standard cover image sizes and dimensions + var image = Image.Black(320*4, 160*4); + + for (var i = 0; i < coverImages.Count; i++) + { + var tile = Image.NewFromFile(coverImages[i], access: Enums.Access.Sequential); + + var x = (i % 2) * (image.Width / 2); + var y = (i / 2) * (image.Height / 2); + + image = image.Insert(tile, x, y); + } + + image.WriteToFile(dest); + return dest; + } } diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 103bd8dea..f6f0ffc3b 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -10,6 +11,7 @@ using API.DTOs.ReadingLists; using API.DTOs.ReadingLists.CBL; using API.Entities; using API.Entities.Enums; +using API.Helpers; using API.SignalR; using Kavita.Common; using Microsoft.Extensions.Logging; @@ -31,6 +33,8 @@ public interface IReadingListService Task ValidateCblFile(int userId, CblReadingList cblReading); Task CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false); + Task CalculateStartAndEndDates(ReadingList readingListWithItems); + Task GenerateMergedImage(int readingListId); } /// @@ -142,6 +146,25 @@ public class ReadingListService : IReadingListService readingList.Promoted = dto.Promoted; readingList.CoverImageLocked = dto.CoverImageLocked; + + if (NumberHelper.IsValidMonth(dto.StartingMonth)) + { + readingList.StartingMonth = dto.StartingMonth; + } + if (NumberHelper.IsValidYear(dto.StartingYear)) + { + readingList.StartingYear = dto.StartingYear; + } + if (NumberHelper.IsValidMonth(dto.EndingMonth)) + { + readingList.EndingMonth = dto.EndingMonth; + } + if (NumberHelper.IsValidYear(dto.EndingYear)) + { + readingList.EndingYear = dto.EndingYear; + } + + if (!dto.CoverImageLocked) { readingList.CoverImageLocked = false; @@ -182,6 +205,7 @@ public class ReadingListService : IReadingListService var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId); if (readingList == null) return true; await CalculateReadingListAgeRating(readingList); + await CalculateStartAndEndDates(readingList); if (!_unitOfWork.HasChanges()) return true; @@ -239,6 +263,7 @@ public class ReadingListService : IReadingListService } await CalculateReadingListAgeRating(readingList); + await CalculateStartAndEndDates(readingList); if (!_unitOfWork.HasChanges()) return true; @@ -254,6 +279,52 @@ public class ReadingListService : IReadingListService await CalculateReadingListAgeRating(readingList, readingList.Items.Select(i => i.SeriesId)); } + /// + /// Calculates the Start month/year and Ending month/year + /// + /// Reading list should have all items + public async Task CalculateStartAndEndDates(ReadingList readingListWithItems) + { + var items = readingListWithItems.Items; + if (readingListWithItems.Items.All(i => i.Chapter == null)) + { + items = + (await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListWithItems.Id, ReadingListIncludes.ItemChapter))?.Items; + } + if (items == null || items.Count == 0) return; + + if (items.First().Chapter == null) + { + _logger.LogError("Tried to calculate release dates for Reading List, but missing Chapter entities"); + return; + } + var maxReleaseDate = items.Max(item => item.Chapter.ReleaseDate); + var minReleaseDate = items.Max(item => item.Chapter.ReleaseDate); + if (maxReleaseDate != DateTime.MinValue) + { + readingListWithItems.EndingMonth = maxReleaseDate.Month; + readingListWithItems.EndingYear = maxReleaseDate.Year; + } + if (minReleaseDate != DateTime.MinValue) + { + readingListWithItems.StartingMonth = minReleaseDate.Month; + readingListWithItems.StartingYear = minReleaseDate.Year; + } + } + + public Task GenerateMergedImage(int readingListId) + { + throw new NotImplementedException(); + // var coverImages = (await _unitOfWork.ReadingListRepository.GetFirstFourCoverImagesByReadingListId(readingListId)).ToList(); + // if (coverImages.Count < 4) return null; + // var fullImages = coverImages + // .Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(); + // + // var combinedFile = ImageService.CreateMergedImage(fullImages, _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, $"{readingListId}.png")); + // // webp needs to be handled + // return combinedFile; + } + /// /// Calculates the highest Age Rating from each Reading List Item /// @@ -522,6 +593,14 @@ public class ReadingListService : IReadingListService if (dryRun) return importSummary; await CalculateReadingListAgeRating(readingList); + await CalculateStartAndEndDates(readingList); + + // For CBL Import only we override pre-calculated dates + if (NumberHelper.IsValidMonth(cblReading.StartMonth)) readingList.StartingMonth = cblReading.StartMonth; + if (NumberHelper.IsValidYear(cblReading.StartYear)) readingList.StartingYear = cblReading.StartYear; + if (NumberHelper.IsValidMonth(cblReading.EndMonth)) readingList.EndingMonth = cblReading.EndMonth; + if (NumberHelper.IsValidYear(cblReading.EndYear)) readingList.EndingYear = cblReading.EndYear; + if (!string.IsNullOrEmpty(readingList.Summary?.Trim())) { readingList.Summary = readingList.Summary?.Trim(); diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 51e25797a..c53f0b132 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -78,7 +78,7 @@ public class SeriesService : ISeriesService series.Metadata.AgeRatingLocked = true; } - if (updateSeriesMetadataDto.SeriesMetadata.ReleaseYear > 1000 && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) + if (NumberHelper.IsValidYear(updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) { series.Metadata.ReleaseYear = updateSeriesMetadataDto.SeriesMetadata.ReleaseYear; series.Metadata.ReleaseYearLocked = true; diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 6b7a373b8..48d75cb18 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -8,6 +8,7 @@ using API.DTOs.Statistics; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Extensions.QueryExtensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -340,29 +341,26 @@ public class StatisticService : IStatisticService .Join(_context.Volume, x => x.chapter.VolumeId, volume => volume.Id, (x, volume) => new {x.appUserProgresses, x.chapter, volume}) .Join(_context.Series, x => x.appUserProgresses.SeriesId, series => series.Id, - (x, series) => new {x.appUserProgresses, x.chapter, x.volume, series}); + (x, series) => new {x.appUserProgresses, x.chapter, x.volume, series}) + .WhereIf(userId > 0, x => x.appUserProgresses.AppUserId == userId) + .WhereIf(days > 0, x => x.appUserProgresses.LastModified >= DateTime.Now.AddDays(days * -1)); - if (userId > 0) - { - query = query.Where(x => x.appUserProgresses.AppUserId == userId); - } - if (days > 0) - { - var date = DateTime.Now.AddDays(days * -1); - query = query.Where(x => x.appUserProgresses.LastModified >= date); - } + // .Where(p => p.chapter.AvgHoursToRead > 0) + // .SumAsync(p => + // p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))) var results = await query.GroupBy(x => new { Day = x.appUserProgresses.LastModified.Date, - x.series.Format + x.series.Format, }) .Select(g => new PagesReadOnADayCount { Value = g.Key.Day, Format = g.Key.Format, - Count = g.Count() + Count = (long) g.Sum(x => + x.chapter.AvgHoursToRead * (x.appUserProgresses.PagesRead / (1.0f * x.chapter.Pages))) }) .OrderBy(d => d.Value) .ToListAsync(); diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index a8e8b8ba4..3f5d81b22 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.Logging; namespace API.Services.Tasks; -internal abstract class GithubReleaseMetadata +internal class GithubReleaseMetadata { /// /// Name of the Tag diff --git a/UI/Web/src/app/_models/preferences/writing-style.ts b/UI/Web/src/app/_models/preferences/writing-style.ts index e4c031fef..5fbade0cf 100644 --- a/UI/Web/src/app/_models/preferences/writing-style.ts +++ b/UI/Web/src/app/_models/preferences/writing-style.ts @@ -2,6 +2,6 @@ * Mode the user is reading the book in. Not applicable with ReaderMode.Webtoon */ export enum WritingStyle{ - Vertical = 0, - Horizontal = 1 + Horizontal = 0, + Vertical = 1, } diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index 10903da0e..daff4f57b 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -30,4 +30,8 @@ export interface ReadingList { * If this is empty or null, the cover image isn't set. Do not use this externally. */ coverImage: string; + startingYear: number; + startingMonth: number; + endingYear: number; + endingMonth: number; } \ No newline at end of file diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.html b/UI/Web/src/app/all-series/_components/all-series/all-series.component.html index cb2eee35f..0845d2596 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.html +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.html @@ -2,7 +2,7 @@

{{title}}

-
{{pagination.totalItems}} Series
+
{{pagination.totalItems | number}} Series
Bookmarks -
{{series.length}} Series
+
{{series.length | number}} Series
- {{selectionCount}} items selected + {{selectionCount | number}} items selected diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss index 5ebd517e9..c4aa6459a 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss @@ -102,7 +102,7 @@ virtual-scroller.empty { display: none; - } +} h2 { display: inline-block; diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index 85937d6c8..402fcaf6e 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -2,7 +2,7 @@

Collections

-
{{collections.length}} Items
+
{{collections.length | number}} Items
-
{{pagination.totalItems}} Series
+
{{pagination.totalItems | number}} Series
diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html index 7e1459ee1..9e7a7c611 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html @@ -213,10 +213,13 @@ NZ0ZV4zm4/L1dfnYNCrjTFq9G03rmj5D+Y4i0OHuL3GFPJytaM54AAAAAElFTkSuQmCC
- {{item.name}} - - - + + {{item.name}} + + + + +
in {{item.libraryName}}
diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html index e8fa22c44..c25f591ff 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html @@ -1,25 +1,58 @@ -
-
-
-
- -
- - - -
- - -
- -
- -
-
+ -

- When you put a number in the reorder input, the item will be inserted at that location and all other items will have their order updated. -

\ No newline at end of file + +
+ +
+
+
+
+ + +
+
+ + + + +
+
+
+
+
+ +
+
+
+
+
+ + +
+ +
+ + + + +
+
+
+
+ + + +

+ When you put a number in the reorder input, the item will be inserted at that location and all other items will have their order updated. +

+ + +
\ No newline at end of file diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.scss b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.scss index 7a1d96b9a..1cab9d886 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.scss +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.scss @@ -9,11 +9,12 @@ .example-box { margin: 5px 0; - //border-bottom: solid 1px #ccc; display: flex; flex-direction: row; box-sizing: border-box; font-size: 14px; + max-height: 140px; + height: 140px; .drag-handle { cursor: move; @@ -59,3 +60,14 @@ border-radius: 5px; width: 100%; } + + + +.virtual-scroller, virtual-scroller { + width: 100%; + height: calc(var(--vh) * 100 - 173px); +} + +virtual-scroller.empty { + display: none; +} \ No newline at end of file diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts index b57200424..d7a80dfac 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.ts @@ -1,5 +1,6 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, Input, Output, TemplateRef } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, Input, Output, TemplateRef, TrackByFunction, ViewChild } from '@angular/core'; +import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller'; export interface IndexUpdateEvent { fromPosition: number; @@ -26,10 +27,19 @@ export class DraggableOrderedListComponent { */ @Input() showRemoveButton: boolean = true; @Input() items: Array = []; + /** + * Parent scroll for virtualize pagination + */ + @Input() parentScroll!: Element | Window; + @Input() trackByIdentity: TrackByFunction = (index: number, item: any) => `${item.id}_${item.order}_${item.title}`; @Output() orderUpdated: EventEmitter = new EventEmitter(); @Output() itemRemove: EventEmitter = new EventEmitter(); @ContentChild('draggableItem') itemTemplate!: TemplateRef; + get BufferAmount() { + return Math.min(this.items.length / 20, 20); + } + constructor(private readonly cdRef: ChangeDetectorRef) { } drop(event: CdkDragDrop) { diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index cc43e1ab6..59483e155 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -1,12 +1,10 @@

- - - + {{readingList?.title}} ()

-
{{items.length}} Items
+
{{items.length | number}} Items
@@ -36,7 +34,7 @@
-
+
@@ -83,6 +81,20 @@
+
+

+ {{readingList.startingMonth | date:'MMM'}} + , + {{readingList.startingYear}} + — + + {{readingList.endingMonth}} + , + {{readingList.endingYear}} + + +

+
@@ -92,22 +104,18 @@
-
-
-
Characters
-
-
- - - - - -
+
+
Characters
+ + + + +
-
+
Nothing added @@ -115,10 +123,10 @@
- - + - diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss index af64858dd..1ce6ed148 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.scss @@ -2,9 +2,31 @@ width: 100%; } +.non-virtualized-container { + width: 100%; + max-height: 140px; + height: 140px; +} + .dropdown-toggle-split { border-top-right-radius: 6px !important; border-bottom-right-radius: 6px !important; border-top-left-radius: 0px !important; border-bottom-left-radius: 0px !important; +} + +.reading-list-years { + color: var(--input-placeholder-color); +} + +.scroll-container { + display: flex; + flex-direction: row; + width: 100%; + height: calc((var(--vh) *100) - 173px); + margin-bottom: 10px; + + &.empty { + height: auto; + } } \ No newline at end of file diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html index 5160582b3..1fa7a21ce 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html @@ -36,8 +36,10 @@ {{item.seriesName}}
+ +
- Released: {{item.releaseDate | date:'short'}} + Released: {{item.releaseDate | date:'longDate'}}
diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html index e3af78958..faa8333cd 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html @@ -3,7 +3,7 @@ Reading Lists -
{{pagination.totalItems}} Items
+
{{pagination.totalItems | number}} Items
+ +
+
+
Starting
+
+ + +
+
+ Must be between 1 and 12 or blank +
+
+
+
+ + +
+
+ Must be between 1 and 12 or blank +
+
+
+
+ +
+
Ending
+
+ + +
+
+ Must be between 1 and 12 or blank +
+
+
+
+ + +
+
+ Must be between 1 and 12 or blank +
+
+
+
+
diff --git a/UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.ts b/UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.ts index 76b8391bc..0c871bd17 100644 --- a/UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.ts +++ b/UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.ts @@ -49,6 +49,10 @@ export class EditReadingListModalComponent implements OnInit, OnDestroy { title: new FormControl(this.readingList.title, { nonNullable: true, validators: [Validators.required] }), summary: new FormControl(this.readingList.summary, { nonNullable: true, validators: [] }), promoted: new FormControl(this.readingList.promoted, { nonNullable: true, validators: [] }), + startingMonth: new FormControl(this.readingList.startingMonth, { nonNullable: true, validators: [Validators.min(1), Validators.max(12)] }), + startingYear: new FormControl(this.readingList.startingYear, { nonNullable: true, validators: [Validators.min(1000)] }), + endingMonth: new FormControl(this.readingList.endingMonth, { nonNullable: true, validators: [Validators.min(1), Validators.max(12)] }), + endingYear: new FormControl(this.readingList.endingYear, { nonNullable: true, validators: [Validators.min(1000)] }), }); this.reviewGroup.get('title')?.valueChanges.pipe( @@ -90,6 +94,10 @@ export class EditReadingListModalComponent implements OnInit, OnDestroy { if (this.reviewGroup.value.title.trim() === '') return; const model = {...this.reviewGroup.value, readingListId: this.readingList.id, coverImageLocked: this.coverImageLocked}; + model.startingMonth = model.startingMonth || 0; + model.startingYear = model.startingYear || 0; + model.endingMonth = model.endingMonth || 0; + model.endingYear = model.endingYear || 0; const apis = [this.readingListService.update(model)]; if (this.selectedCover !== '') { diff --git a/UI/Web/src/app/reading-list/reading-list.module.ts b/UI/Web/src/app/reading-list/reading-list.module.ts index 6cd2ba2d8..778d8b111 100644 --- a/UI/Web/src/app/reading-list/reading-list.module.ts +++ b/UI/Web/src/app/reading-list/reading-list.module.ts @@ -18,6 +18,7 @@ import { FileUploadModule } from '@iplab/ngx-file-upload'; import { CblConflictReasonPipe } from './_pipes/cbl-conflict-reason.pipe'; import { StepTrackerComponent } from './_components/step-tracker/step-tracker.component'; import { CblImportResultPipe } from './_pipes/cbl-import-result.pipe'; +import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; @NgModule({ declarations: [ @@ -48,6 +49,7 @@ import { CblImportResultPipe } from './_pipes/cbl-import-result.pipe'; ReadingListRoutingModule, NgbAccordionModule, // Import CBL FileUploadModule, // Import CBL + VirtualScrollerModule, ], exports: [ AddToListModalComponent, diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index e1e2f96b6..e919489ac 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -73,13 +73,29 @@
- +
+ +
+ + +
+
+
+
diff --git a/openapi.json b/openapi.json index 611e28f53..87b756e25 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.1.16" + "version": "0.7.1.19" }, "servers": [ { @@ -12383,6 +12383,26 @@ "type": "string", "format": "date-time" }, + "startingYear": { + "type": "integer", + "description": "Minimum Year the Reading List starts", + "format": "int32" + }, + "startingMonth": { + "type": "integer", + "description": "Minimum Month the Reading List starts", + "format": "int32" + }, + "endingYear": { + "type": "integer", + "description": "Maximum Year the Reading List starts", + "format": "int32" + }, + "endingMonth": { + "type": "integer", + "description": "Maximum Month the Reading List starts", + "format": "int32" + }, "appUserId": { "type": "integer", "format": "int32" @@ -12420,6 +12440,26 @@ "type": "string", "description": "This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.", "nullable": true + }, + "startingYear": { + "type": "integer", + "description": "Minimum Year the Reading List starts", + "format": "int32" + }, + "startingMonth": { + "type": "integer", + "description": "Minimum Month the Reading List starts", + "format": "int32" + }, + "endingYear": { + "type": "integer", + "description": "Maximum Year the Reading List starts", + "format": "int32" + }, + "endingMonth": { + "type": "integer", + "description": "Maximum Month the Reading List starts", + "format": "int32" } }, "additionalProperties": false @@ -14580,6 +14620,22 @@ }, "coverImageLocked": { "type": "boolean" + }, + "startingMonth": { + "type": "integer", + "format": "int32" + }, + "startingYear": { + "type": "integer", + "format": "int32" + }, + "endingMonth": { + "type": "integer", + "format": "int32" + }, + "endingYear": { + "type": "integer", + "format": "int32" } }, "additionalProperties": false