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