diff --git a/API.Tests/Helpers/ScannerHelper.cs b/API.Tests/Helpers/ScannerHelper.cs index 653efebb1..9ed6d8005 100644 --- a/API.Tests/Helpers/ScannerHelper.cs +++ b/API.Tests/Helpers/ScannerHelper.cs @@ -35,7 +35,8 @@ public class ScannerHelper private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases"); private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png"); - private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" }; + private static readonly string[] ComicInfoExtensions = [".cbz", ".cbr", ".zip", ".rar"]; + private static readonly string[] EpubExtensions = [".epub"]; public ScannerHelper(IUnitOfWork unitOfWork, ITestOutputHelper testOutputHelper) { @@ -43,7 +44,7 @@ public class ScannerHelper _testOutputHelper = testOutputHelper; } - public async Task GenerateScannerData(string testcase, Dictionary comicInfos = null) + public async Task GenerateScannerData(string testcase, Dictionary? comicInfos = null) { var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos); @@ -113,7 +114,7 @@ public class ScannerHelper - private async Task GenerateTestDirectory(string mapPath, Dictionary comicInfos = null) + private async Task GenerateTestDirectory(string mapPath, Dictionary? comicInfos = null) { // Read the map file var mapContent = await File.ReadAllTextAsync(mapPath); @@ -130,7 +131,7 @@ public class ScannerHelper Directory.CreateDirectory(testDirectory); // Generate the files and folders - await Scaffold(testDirectory, filePaths, comicInfos); + await Scaffold(testDirectory, filePaths ?? [], comicInfos); _testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}"); @@ -138,7 +139,7 @@ public class ScannerHelper } - public async Task Scaffold(string testDirectory, List filePaths, Dictionary comicInfos = null) + public async Task Scaffold(string testDirectory, List filePaths, Dictionary? comicInfos = null) { foreach (var relativePath in filePaths) { @@ -157,6 +158,10 @@ public class ScannerHelper { CreateMinimalCbz(fullPath, info); } + else if (EpubExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var epubInfo)) + { + CreateMinimalEpub(fullPath, epubInfo); + } else { // Create an empty file @@ -205,4 +210,165 @@ public class ScannerHelper return stringWriter.ToString().Replace("""""", @""); } + + private void CreateMinimalEpub(string filePath, ComicInfo? comicInfo = null) + { + using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create)) + { + // EPUB requires a mimetype file as the first entry (uncompressed) + var mimetypeEntry = archive.CreateEntry("mimetype", CompressionLevel.NoCompression); + using (var mimetypeStream = mimetypeEntry.Open()) + using (var writer = new StreamWriter(mimetypeStream, Encoding.ASCII)) + { + writer.Write("application/epub+zip"); + } + + // Create META-INF/container.xml + var containerEntry = archive.CreateEntry("META-INF/container.xml"); + using (var containerStream = containerEntry.Open()) + using (var writer = new StreamWriter(containerStream, Encoding.UTF8)) + { + writer.Write(""" + + + + + + + """); + } + + // Create content.opf with metadata + var contentOpf = GenerateContentOpf(comicInfo); + var contentEntry = archive.CreateEntry("OEBPS/content.opf"); + using (var contentStream = contentEntry.Open()) + using (var writer = new StreamWriter(contentStream, Encoding.UTF8)) + { + writer.Write(contentOpf); + } + + // Add a minimal chapter XHTML file + var chapterEntry = archive.CreateEntry("OEBPS/chapter1.xhtml"); + using (var chapterStream = chapterEntry.Open()) + using (var writer = new StreamWriter(chapterStream, Encoding.UTF8)) + { + writer.Write(""" + + + + + Chapter 1 + + +

Test content.

+ + + """); + } + + // Add the cover image + archive.CreateEntryFromFile(_imagePath, "OEBPS/cover.png"); + } + Console.WriteLine($"Created minimal EPUB archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata."); + } + + private static string GenerateContentOpf(ComicInfo? comicInfo) + { + var sb = new StringBuilder(); + sb.AppendLine(""""""); + sb.AppendLine(""""""); + + // Metadata section + sb.AppendLine(" "); + + if (comicInfo != null) + { + if (!string.IsNullOrEmpty(comicInfo.Title)) + sb.AppendLine($" {EscapeXml(comicInfo.Title)}"); + else + sb.AppendLine(" Untitled"); + + if (!string.IsNullOrEmpty(comicInfo.Series)) + { + sb.AppendLine($" {EscapeXml(comicInfo.Series)}"); + sb.AppendLine(" series"); + } + + if (!string.IsNullOrEmpty(comicInfo.Writer)) + sb.AppendLine($" {EscapeXml(comicInfo.Writer)}"); + + if (!string.IsNullOrEmpty(comicInfo.Publisher)) + sb.AppendLine($" {EscapeXml(comicInfo.Publisher)}"); + + if (!string.IsNullOrEmpty(comicInfo.Summary)) + sb.AppendLine($" {EscapeXml(comicInfo.Summary)}"); + + if (!string.IsNullOrEmpty(comicInfo.LanguageISO)) + sb.AppendLine($" {EscapeXml(comicInfo.LanguageISO)}"); + else + sb.AppendLine(" en"); + + if (!string.IsNullOrEmpty(comicInfo.Isbn)) + sb.AppendLine($" {EscapeXml(comicInfo.Isbn)}"); + else + sb.AppendLine($" urn:uuid:{Guid.NewGuid()}"); + + if (comicInfo.Year > 0) + { + var date = $"{comicInfo.Year:D4}"; + if (comicInfo.Month > 0) + { + date += $"-{comicInfo.Month:D2}"; + if (comicInfo.Day > 0) + date += $"-{comicInfo.Day:D2}"; + } + sb.AppendLine($" {date}"); + } + + if (!string.IsNullOrEmpty(comicInfo.TitleSort)) + sb.AppendLine($" "); + + if (!string.IsNullOrEmpty(comicInfo.SeriesSort)) + sb.AppendLine($" "); + + if (!string.IsNullOrEmpty(comicInfo.Number)) + sb.AppendLine($" "); + } + else + { + sb.AppendLine(" Untitled"); + sb.AppendLine(" en"); + sb.AppendLine($" urn:uuid:{Guid.NewGuid()}"); + } + + sb.AppendLine(" "); + + // Manifest section + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + + // Spine section + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + + sb.AppendLine(""); + + return sb.ToString(); + } + + private static string EscapeXml(string text) + { + if (string.IsNullOrEmpty(text)) return text; + + return text + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); + } + } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 340116d18..9d6837ccb 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -133,7 +133,6 @@ public class SettingsController : BaseApiController /// Is the minimum information setup for Email to work /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpGet("is-email-setup")] public async Task> IsEmailSetup() { diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index ccdce18b7..c3e5df2bf 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -120,6 +120,7 @@ public class UsersController : BaseApiController existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled; existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots; + existingPreferences.DataSaver = preferencesDto.DataSaver; var allLibs = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)) .Select(l => l.Id).ToList(); diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index 8edec14f1..de3216a51 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -11,6 +11,7 @@ public sealed record ReadingListItemDto public int ChapterId { get; init; } public int SeriesId { get; init; } public string? SeriesName { get; set; } + public string? SeriesSortName { get; set; } public MangaFormat SeriesFormat { get; set; } public int PagesRead { get; set; } public int PagesTotal { get; set; } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 9a1b1a70b..3e38d3740 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -37,6 +37,9 @@ public sealed record UserPreferencesDto /// [Required] public bool ColorScapeEnabled { get; set; } = true; + /// + [Required] + public bool DataSaver { get; set; } = false; /// public bool AniListScrobblingEnabled { get; set; } diff --git a/API/Data/Migrations/20251009150922_DataSaverUserSetting.Designer.cs b/API/Data/Migrations/20251009150922_DataSaverUserSetting.Designer.cs new file mode 100644 index 000000000..690f5152d --- /dev/null +++ b/API/Data/Migrations/20251009150922_DataSaverUserSetting.Designer.cs @@ -0,0 +1,3928 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +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("20251009150922_DataSaverUserSetting")] + partial class DataSaverUserSetting + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + 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("AniListAccessToken") + .HasColumnType("TEXT"); + + 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("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OidcId") + .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("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + 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.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Likes") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedSlotIndex") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserAnnotation"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + + 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.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("BookReaderHighlightSlots") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + 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("ColorScapeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("DataSaver") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SocialPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true}"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + 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("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + 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.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + 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("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.EpubFont", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("EpubFont"); + }); + + 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.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + 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("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + 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.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + 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("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .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("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + 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.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .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.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + 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("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("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + 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("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.AppUserAnnotation", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + 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.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .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.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .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.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + 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("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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("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("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .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("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("Annotations"); + + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences") + .IsRequired(); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + 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/20251009150922_DataSaverUserSetting.cs b/API/Data/Migrations/20251009150922_DataSaverUserSetting.cs new file mode 100644 index 000000000..0a744dfc7 --- /dev/null +++ b/API/Data/Migrations/20251009150922_DataSaverUserSetting.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class DataSaverUserSetting : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DataSaver", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DataSaver", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 850ad5130..1e44acecd 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -571,6 +571,9 @@ namespace API.Data.Migrations .HasColumnType("INTEGER") .HasDefaultValue(true); + b.Property("DataSaver") + .HasColumnType("INTEGER"); + b.Property("EmulateBook") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index c11c94725..cacc14b4d 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -311,7 +311,7 @@ public class ReadingListRepository : IReadingListRepository .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) .RestrictAgainstAgeRestriction(user.GetAgeRestriction()); - query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.NormalizedTitle); + query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.Title); var finalQuery = query.ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking(); @@ -383,11 +383,13 @@ public class ReadingListRepository : IReadingListRepository { ReadingListItem = rli, Chapter = chapter, + FileSize = _context.MangaFile.Where(f => f.ChapterId == chapter.Id).Sum(f => (long?)f.Bytes) ?? 0 }) .Join(_context.Volume, x => x.ReadingListItem.VolumeId, volume => volume.Id, (x, volume) => new { x.ReadingListItem, x.Chapter, + x.FileSize, Volume = volume }) .Join(_context.Series, x => x.ReadingListItem.SeriesId, series => series.Id, (x, series) => new @@ -395,6 +397,7 @@ public class ReadingListRepository : IReadingListRepository x.ReadingListItem, x.Chapter, x.Volume, + x.FileSize, Series = series }) .Where(x => userLibraries.Contains(x.Series.LibraryId)) @@ -407,6 +410,7 @@ public class ReadingListRepository : IReadingListRepository x.Chapter, x.Volume, x.Series, + x.FileSize, ProgressGroup = progressGroup }) .SelectMany( @@ -417,6 +421,7 @@ public class ReadingListRepository : IReadingListRepository x.Chapter, x.Volume, x.Series, + x.FileSize, Progress = progress, PagesRead = progress != null ? progress.PagesRead : 0, HasProgress = progress != null, @@ -447,6 +452,7 @@ public class ReadingListRepository : IReadingListRepository Order = item.ReadingListItem.Order, SeriesId = item.ReadingListItem.SeriesId, SeriesName = item.Series.Name, + SeriesSortName = item.Series.SortName, SeriesFormat = item.Series.Format, PagesTotal = item.Chapter.Pages, PagesRead = item.PagesRead, @@ -459,7 +465,7 @@ public class ReadingListRepository : IReadingListRepository LibraryType = library.Type, ChapterTitleName = item.Chapter.TitleName, LibraryName = library.Name, - FileSize = item.Chapter.Files.Sum(f => f.Bytes), // TODO: See if we can put FileSize on the chapter in future + FileSize = item.FileSize, Summary = item.Chapter.Summary, IsSpecial = item.Chapter.IsSpecial, LastReadingProgressUtc = item.Progress?.LastModifiedUtc @@ -513,6 +519,7 @@ public class ReadingListRepository : IReadingListRepository (data, s) => new { SeriesName = s.Name, + SortName = s.SortName, SeriesFormat = s.Format, s.LibraryId, data.ReadingListItem, @@ -541,6 +548,7 @@ public class ReadingListRepository : IReadingListRepository Order = x.Data.ReadingListItem.Order, SeriesId = x.Data.ReadingListItem.SeriesId, SeriesName = x.Data.SeriesName, + SeriesSortName = x.Data.SortName, SeriesFormat = x.Data.SeriesFormat, PagesTotal = x.Data.TotalPages, ChapterNumber = x.Data.ChapterNumber, diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 25b4f61a7..88dd7729e 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -171,6 +171,13 @@ public class AppUserPreferences /// UI Site Global Setting: Should Kavita render ColorScape gradients /// public bool ColorScapeEnabled { get; set; } = true; + + /// + /// Enable data saver mode across Kavita, limiting information that is pre-fetched + /// + /// Currenty only integrated into the PDF reader + public bool DataSaver { get; set; } = false; + #endregion #region KavitaPlus diff --git a/API/Helpers/PdfMetadataExtractor.cs b/API/Helpers/PdfMetadataExtractor.cs index dcf984247..ef959896f 100644 --- a/API/Helpers/PdfMetadataExtractor.cs +++ b/API/Helpers/PdfMetadataExtractor.cs @@ -10,41 +10,40 @@ using Microsoft.Extensions.Logging; namespace API.Helpers; #nullable enable -/** - * Contributed by https://github.com/microtherion - * - * All references to the "PDF Spec" (section numbers, etc.) refer to the - * PDF 1.7 Specification a.k.a. PDF32000-1:2008 - * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf - */ -/** - * Reference for PDF Metadata Format - %PDF-1.4 ← Header +// Contributed by https://github.com/microtherion +// +// All references to the "PDF Spec" (section numbers, etc.) refer to the +// PDF 1.7 Specification a.k.a. PDF32000-1:2008 +// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf - Object 1 0 obj ← Objects containing content - << /Type /Catalog ... >> - endobj - - Object 2 0 obj - << /Type /Info ... >> - endobj - - ...more objects... - - xref ← Cross-reference table - 0 6 - 0000000000 65535 f - 0000000015 00000 n ← Object 1 is at byte offset 15 - 0000000109 00000 n ← Object 2 is at byte offset 109 - ... - - trailer ← Trailer dictionary - << /Size 6 /Root 1 0 R /Info 2 0 R >> - startxref - 1234 ← Byte offset where xref starts - %%EOF - */ +// Reference for PDF Metadata Format +// > +// endobj +// +// Object 2 0 obj +// << /Type /Info ... >> +// endobj +// +// ...more objects... +// +// xref ← Cross-reference table +// 0 6 +// 0000000000 65535 f +// 0000000015 00000 n ← Object 1 is at byte offset 15 +// 0000000109 00000 n ← Object 2 is at byte offset 109 +// ... +// +// trailer ← Trailer dictionary +// << /Size 6 /Root 1 0 R /Info 2 0 R >> +// startxref +// 1234 ← Byte offset where xref starts +// %%EOF +// ]]> /// /// Parse PDF file and try to extract as much metadata as possible. @@ -1591,6 +1590,7 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor case PdfLexer.TokenType.Name: case PdfLexer.TokenType.String: case PdfLexer.TokenType.ObjectRef: + case PdfLexer.TokenType.Keyword: break; case PdfLexer.TokenType.ArrayStart: { @@ -1602,8 +1602,17 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor SkipDictionary(); break; } + case PdfLexer.TokenType.StreamStart: + { + // If we encounter a stream, we need to skip it properly + // This is tricky because we need the Length from the dictionary + // For now, throw a more informative exception + throw new PdfMetadataExtractorException( + "Encountered stream object in unexpected context - PDF may have inline streams in dictionary"); + } default: - throw new PdfMetadataExtractorException("Unexpected token in SkipValue"); + throw new PdfMetadataExtractorException( + $"Unexpected token type in SkipValue: {token.Type} with value: {token.Value}"); } } diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 8c4f63430..d70607f46 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -244,7 +244,7 @@ public class ReadingListService : IReadingListService // Collect all Ids to remove var itemIdsToRemove = items.Where(item => item.PagesRead == item.PagesTotal).Select(item => item.Id).ToList(); - if (!itemIdsToRemove.Any()) return true; + if (itemIdsToRemove.Count == 0) return true; try { var listItems = @@ -360,8 +360,7 @@ public class ReadingListService : IReadingListService private async Task CalculateReadingListAgeRating(ReadingList readingList, IEnumerable seriesIds) { var ageRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds); - if (ageRating == null) readingList.AgeRating = AgeRating.Unknown; - else readingList.AgeRating = (AgeRating) ageRating; + readingList.AgeRating = ageRating; } /// diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs index 14f42c989..76cbf9211 100644 --- a/API/Services/Tasks/Scanner/Parser/BookParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -1,4 +1,5 @@ -using API.Data.Metadata; +using System.IO; +using API.Data.Metadata; using API.Entities.Enums; namespace API.Services.Tasks.Scanner.Parser; @@ -7,8 +8,26 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer { public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null) { - var info = bookService.ParseInfo(filePath); - if (info == null) return null; + ParserInfo info; + if (enableMetadata) + { + info = bookService.ParseInfo(filePath); + if (info == null) return null; + } + else + { + var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); + info = new ParserInfo + { + Filename = Path.GetFileName(filePath), + Format = MangaFormat.Epub, + Title = Parser.RemoveExtensionIfSupported(fileName)!, + FullFilePath = Parser.NormalizePath(filePath), + Series = Parser.ParseSeries(fileName, type), + Chapters = Parser.ParseChapter(fileName, type), + Volumes = Parser.ParseVolume(fileName, type), + }; + } info.ComicInfo = comicInfo; diff --git a/UI/Web/README.md b/UI/Web/README.md index 65b9fb8bd..a5a21a4b5 100644 --- a/UI/Web/README.md +++ b/UI/Web/README.md @@ -27,9 +27,16 @@ Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e te ## Connecting to your dev server via your phone or any other compatible client on local network -Update `IP` constant in `src/environments/environment.ts` to your dev machine's ip instead of `localhost`. +Run `npm run start-proxy` -Run `npm run start` +## Testing OIDC + +There's two options, + +1) Run the proxy and correct the port after redirect (on login). +2) Run `build-backend` or `build-backend-prod`, and use `localhost:5000` to test. This requires you to rebuild after each change + +Do **NOT** commit appsettings.development.json while testing OIDC. It'll contain your secret key ## Notes: - injected services should be at the top of the file diff --git a/UI/Web/src/_tag-card-common.scss b/UI/Web/src/_tag-card-common.scss index 39a1e87fd..dab6ababd 100644 --- a/UI/Web/src/_tag-card-common.scss +++ b/UI/Web/src/_tag-card-common.scss @@ -13,8 +13,11 @@ } .tag-card:hover { - background-color: #3a3a3a; + background-color: var(--card-hover-bg-color); //transform: translateY(-3px); // Cool effect but has a weird background issue. ROBBIE: Fix this + & .tag-name, & .tag-meta { + color: var(--card-hover-text-color) + } } .tag-name { diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index be26da1b1..5db987355 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -15,6 +15,7 @@ export interface Preferences { locale: string; bookReaderHighlightSlots: HighlightSlot[]; colorScapeEnabled: boolean; + dataSaver: boolean; // Kavita+ aniListScrobblingEnabled: boolean; diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index 646360153..dc46e3ca9 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -9,6 +9,7 @@ export interface ReadingListItem { pagesRead: number; pagesTotal: number; seriesName: string; + seriesSortName: string; seriesFormat: MangaFormat; seriesId: number; chapterId: number; diff --git a/UI/Web/src/app/_services/font.service.ts b/UI/Web/src/app/_services/font.service.ts index 16bc58fd2..df4a02d3a 100644 --- a/UI/Web/src/app/_services/font.service.ts +++ b/UI/Web/src/app/_services/font.service.ts @@ -38,7 +38,7 @@ export class FontService { getFontFace(font: EpubFont): FontFace { if (font.provider === FontProvider.System) { - return new FontFace(font.name, `url('/assets/fonts/${font.name}/${font.fileName}')`); + return new FontFace(font.name, `url('assets/fonts/${font.name}/${font.fileName}')`); } return new FontFace(font.name, `url(${this.baseUrl}font?fontId=${font.id}&apiKey=${this.encodedKey})`); diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss index 34b611b9f..bc30d99df 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss @@ -28,12 +28,12 @@ color: var(--dropdown-item-text-color); background-color: var(--dropdown-item-bg-color); &:hover { - color: var(--dropdown-item-text-color); + color: var(--dropdown-item-hover-text-color); background-color: var(--dropdown-item-hover-bg-color); cursor: pointer; } &:focus-visible { - color: var(--dropdown-item-text-color); + color: var(--dropdown-item-hover-text-color); background-color: var(--dropdown-item-hover-bg-color); } } diff --git a/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss b/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss index 0c9c9d032..a8071c571 100644 --- a/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss +++ b/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss @@ -58,6 +58,10 @@ cursor: pointer; } + .card-title { + color: var(--card-overlay-text-color); + } + .overlay-information--centered { position: absolute; border-radius: 15px; diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index 7c4583f06..fe5a5f948 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -94,6 +94,7 @@
diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html index 02428eca9..92a5ccfe9 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.html @@ -19,26 +19,26 @@ }
+ @if (clickToPaginate() && !hidePagination()) { +
+
+
+
+ }
- @if (clickToPaginate() && !hidePagination()) { -
-
- }
+ [ngClass]="{'immersive' : immersiveMode()}" + (mousedown)="mouseDown($event)" >
-
+
@if (isLoading()) {
{{ t('loading-book') }} diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index bf31d2855..b504d5e2a 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -84,6 +84,7 @@ $action-bar-height: 38px; } .center-group { + display: block; justify-self: center; } @@ -98,6 +99,10 @@ $action-bar-height: 38px; grid-template-columns: auto 1fr; } + .center-group { + display: none; + } + .right-group { justify-self: end; } @@ -234,6 +239,7 @@ $action-bar-height: 38px; bottom: 0; left: 0; writing-mode: horizontal-tb; + z-index: 4; } @@ -306,14 +312,21 @@ $pagination-opacity: 0; //$pagination-opacity: 0.7; - +.tap-to-paginate { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 0px; + z-index: 3; +} .right { position: absolute; right: 0px; top: $action-bar-height; width: 20vw; - z-index: 3; + height: calc(100vh - $action-bar-height*2); background: $pagination-color; border-color: transparent; border: none !important; @@ -323,6 +336,7 @@ $pagination-opacity: 0; &.immersive { top: 0; + height: 100vh; } &.no-pointer-events { @@ -335,8 +349,8 @@ $pagination-opacity: 0; position: absolute; right: 17px; top: $action-bar-height; - width: 18%; - z-index: 3; + width: 18vw; + height: calc(100vh - $action-bar-height*2); background: $pagination-color; opacity: $pagination-opacity; border-color: transparent; @@ -346,6 +360,7 @@ $pagination-opacity: 0; &.immersive { top: 0; + height: 100vh; } } @@ -354,17 +369,17 @@ $pagination-opacity: 0; left: 0px; top: $action-bar-height; width: 20vw; + height: calc(100vh - $action-bar-height*2); background: $pagination-color; opacity: $pagination-opacity; border-color: transparent; border: none !important; - z-index: 3; outline: none; - height: 100vw; cursor: pointer; &.immersive { top: 0px; + height: 100vh; } } @@ -410,7 +425,7 @@ $pagination-opacity: 0; i { background-color: unset; - color: var(--br-actionbar-button-text-color); + color: var(--br-actionbar-button-text-color) !important; } &:active { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 0829f6204..7f6bd2a7d 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -359,6 +359,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ debugMode = model(!environment.production && true); + /** + * Will be set to true if this.scroll(...) is called but the actual scroll is still delayed + * This can also be used to debug glitches or race conditions related to page scrolling + * For instance, when we invoke a scroll action, but another scroll is scheduled to be triggered afterward + */ + hasDelayedScroll: boolean = false; @ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef; @@ -506,23 +512,26 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } - get PageHeightForPagination() { + pageHeightForPagination = computed(() => { const layoutMode = this.layoutMode(); const immersiveMode = this.immersiveMode(); - const widthHeight = this.windowHeight(); - if (layoutMode=== BookPageLayoutMode.Default) { + if (layoutMode === BookPageLayoutMode.Default) { + // Ensure Angular updates this pageHeightForPagination when these signal have an update + if (this.isLoading()) return; + this.windowHeight(); + this.writingStyle(); + // if the book content is less than the height of the container, override and return height of container for pagination area if (this.bookContainerElemRef?.nativeElement?.clientHeight > this.bookContentElemRef?.nativeElement?.clientHeight) { return (this.bookContainerElemRef?.nativeElement?.clientHeight || 0) + 'px'; } - return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (immersiveMode ? 0 : 1)) * 2) + 'px'; + return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (immersiveMode ? 0 : 1)) * 2) + 'px'; } - if (immersiveMode) return widthHeight + 'px'; - return (widthHeight) - (this.topOffset * 2) + 'px'; - } + return '100%'; + }); constructor() { this.navService.hideNavBar(); @@ -534,9 +543,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const layoutMode = this.layoutMode(); const writingStyle = this.writingStyle(); - const windowWidth = this.windowWidth(); - const marginLeft = this.pageStyles()['margin-left']; - const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2); + // const windowWidth = this.windowWidth(); + // const marginLeft = this.pageStyles()['margin-left']; + // const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2); const base = writingStyle === WritingStyle.Vertical ? this.pageHeight() : this.pageWidth(); @@ -950,27 +959,33 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.updateWidthAndHeightCalcs(); this.updateImageSizes(); + // Refresh page styles to handle margin changes on window resize + this.applyPageStyles(this.pageStyles()); + // Attempt to restore the reading position this.snapScrollOnResize(); + afterFrame(() => { this.injectImageBookmarkIndicators(true); }); } /** - * Only applies to non BookPageLayoutMode. Default and WritingStyle Horizontal + * Only applies to non BookPageLayoutMode.Default and WritingStyle Horizontal * @private */ private snapScrollOnResize() { const layoutMode = this.layoutMode(); if (layoutMode === BookPageLayoutMode.Default) return; + const resumeElement = this.lastSeenScrollPartPath || (this.getFirstVisibleElementXPath() ?? ''); + if (resumeElement) { - const resumeElement = this.getFirstVisibleElementXPath() ?? null; - if (resumeElement !== null) { - - const element = this.getElementFromXPath(resumeElement); - //console.log('Attempting to snap to element: ', element); + if (this.debugMode()) { + const element = this.getElementFromXPath(resumeElement); + //console.log('Attempting to snap to element: ', element); + this.logSelectedElement('yellow'); + } this.scrollTo(resumeElement, 30); // This works pretty well, but not perfect } @@ -1360,7 +1375,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } this.document.documentElement.style.setProperty('--book-reader-content-max-height', maxHeight); this.document.documentElement.style.setProperty('--book-reader-content-max-width', maxWidth); - } updateSingleImagePageStyles() { @@ -1401,7 +1415,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Virtual Paging stuff this.updateWidthAndHeightCalcs(); this.applyLayoutMode(this.layoutMode()); - this.addEmptyPageIfRequired(); + // this.addEmptyPageIfRequired(); // Already called in this.applyPageStyles() // Find all the part ids and their top offset this.setupPageAnchors(); @@ -1415,7 +1429,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // we need to click the document before arrow keys will scroll down. this.reader.nativeElement.focus(); - this.scroll(() => this.handleScrollEvent()); // Will set lastSeenXPath and save progress + afterFrame(() => this.handleScrollEvent()); // Will set lastSeenXPath and save progress this.isLoading.set(false); this.cdRef.markForCheck(); @@ -1425,8 +1439,15 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } private scroll(lambda: () => void) { + if (this.hasDelayedScroll) console.warn("Another scroll operation is still pending while this scroll function is being called again"); + this.hasDelayedScroll = true; + + // `afterFrame() + setTimeout()` can likely be replaced with `requestAnimationFrame()` instead afterFrame(() => { - setTimeout(lambda, SCROLL_DELAY) + setTimeout(() => { + this.hasDelayedScroll = false; + lambda(); + }, SCROLL_DELAY) }); } @@ -1485,26 +1506,34 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } private addEmptyPageIfRequired(): void { + const bookContentElem = this.bookContentElemRef.nativeElement; + const oldEmptyGap = bookContentElem.querySelector('.kavita-empty-gap'); + if (this.layoutMode() !== BookPageLayoutMode.Column2 || this.isSingleImagePage) { + oldEmptyGap?.remove(); // We don't need empty gap for this condition return; } const pageSize = this.pageSize(); - const [_, totalScroll] = this.getScrollOffsetAndTotalScroll(); + let [_, totalScroll] = this.getScrollOffsetAndTotalScroll(); + + if (oldEmptyGap) totalScroll -= pageSize/2; const lastPageSize = totalScroll % pageSize; if (lastPageSize >= pageSize / 2 || lastPageSize === 0) { // The last page needs more than one column, no pages will be duplicated + oldEmptyGap?.remove(); return; } // Need to adjust height with the column gap to ensure we don't have too much extra page - const columnHeight = this.pageHeight() - COLUMN_GAP; - const emptyPage = this.renderer.createElement('div'); + const columnHeight = this.pageHeight() - (COLUMN_GAP * 2); + const emptyPage = oldEmptyGap ?? this.renderer.createElement('div'); + emptyPage.classList.add('kavita-empty-gap'); this.renderer.setStyle(emptyPage, 'height', columnHeight + 'px'); this.renderer.setStyle(emptyPage, 'width', this.columnWidth()); - this.renderer.appendChild(this.bookContentElemRef.nativeElement, emptyPage); + this.renderer.appendChild(bookContentElem, emptyPage); } goBack() { @@ -1548,7 +1577,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (currentVirtualPage > 1) { // Calculate the target scroll position for the previous page - const targetScroll = (currentVirtualPage - 2) * pageSize - (this.layoutMode() === BookPageLayoutMode.Column2 ? 3 : 0) + const targetScroll = (currentVirtualPage - 2) * pageSize; const isVertical = this.writingStyle() === WritingStyle.Vertical; @@ -1601,7 +1630,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (currentVirtualPage < totalVirtualPages) { // Calculate the target scroll position for the next page - const targetScroll = (currentVirtualPage * pageSize) + (this.layoutMode() === BookPageLayoutMode.Column2 ? 1 : 0); + const targetScroll = (currentVirtualPage * pageSize); const isVertical = this.writingStyle() === WritingStyle.Vertical; // +0 apparently goes forward 1 virtual page... @@ -1649,9 +1678,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const columnGapModifier = this.columnGapModifier(); if (this.readingSectionElemRef == null) return 0; - // Give an additional pixels for buffer - return this.readingSectionElemRef.nativeElement.clientWidth - margin - + (COLUMN_GAP * columnGapModifier); + return this.reader.nativeElement.offsetWidth - margin + (COLUMN_GAP * columnGapModifier); }); columnGapModifier = computed(() => { @@ -1674,13 +1701,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { }); - getVerticalPageWidth() { + getVerticalPageWidth = computed(() => { if (!(this.pageStyles() || {}).hasOwnProperty('margin-left')) return 0; // TODO: Test this, added for safety during refactor - const margin = (window.innerWidth * (parseInt(this.pageStyles()['margin-left'], 10) / 100)) * 2; - const windowWidth = window.innerWidth || document.documentElement.clientWidth; + const margin = (this.windowWidth() * (parseInt(this.pageStyles()['margin-left'], 10) / 100)) * 2; + const windowWidth = this.windowWidth() || document.documentElement.clientWidth; return windowWidth - margin; - } + }); convertVwToPx(vwValue: number) { const viewportWidth = Math.max(this.readingSectionElemRef?.nativeElement?.clientWidth ?? 0, window.innerWidth || 0); @@ -1741,21 +1768,40 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { getFirstVisibleElementXPath() { let resumeElement: string | null = null; - if (!this.bookContentElemRef || !this.bookContentElemRef.nativeElement) return null; + const bookContentElement = this.bookContentElemRef?.nativeElement; + if (!bookContentElement) return null; //const container = this.getViewportBoundingRect(); - const intersectingEntries = Array.from(this.bookContentElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) + const intersectingEntries = Array.from(bookContentElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span,figure')) .filter(element => !element.classList.contains('no-observe')) - .filter(entry => { - //return this.isPartiallyContainedIn(container, entry); - return this.utilityService.isInViewport(entry, this.topOffset); + .filter(element => { + //return this.isPartiallyContainedIn(container, element); + return this.utilityService.isInViewport(element, this.topOffset) + + /* Remove main container element +
<-- bookContentElement +
<--- we don't need this + + ... +
+
+ */ + && element.parentElement !== bookContentElement; + }) + .filter((element, i, entries) => { + // Remove any children element contained in another element that exist on this entries + return !entries.some(item => element !== item && item.contains(element)) + + // Remove element that don't have any content + && (element.textContent?.trim().length || element.querySelectorAll('img, svg').length !== 0 || /^(img|svg)$/im.test(element.tagName)); }); intersectingEntries.sort((a, b) => this.sortElementsForLayout(a, b)); if (intersectingEntries.length > 0) { - let path = this.readerService.getXPathTo(intersectingEntries[0]); + const element = this.findTopLevelElement(intersectingEntries[0], intersectingEntries[1], bookContentElement); + let path = this.readerService.getXPathTo(element); if (path === '') return; resumeElement = path; @@ -1763,6 +1809,63 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return resumeElement; } + /** + * Finds the top level element that has the same parent. + * Illustrated with example below: + * + *
+ *

<-- We want to get this element instead + * ... target ... + *

+ *

+ * ... nextSibling ... + *

+ *
+ */ + private findTopLevelElement(target: Element, nextSibling: Element, root: Element): Element | null { + + // If no sibling provided, then lets transverse to parent element where the element display is not inline + if (nextSibling == null) { + let current: Element | null = target; + while (current && current !== root) { + const displayStyle = window.getComputedStyle(current).getPropertyValue('display'); + + if (!displayStyle.includes('inline')) return current; + current = current.parentElement; + } + + return current; + } + + // Immediately return if it's already sibling + if (target.parentElement === nextSibling.parentElement) return target; + + const ancestors: Element[] = []; + let current: Element | null = null + + // Collect all parent element from the next sibling + current = nextSibling.parentElement; + while (current && current !== root) { + ancestors.push(current); + current = current.parentElement; + } + + // Traverse up from target to find the similar parent with nextSibling + current = target; + while (current && current !== root) { + let parent: Element | null = current.parentElement; + + if (parent && ancestors.includes(parent)) { + return current; + } + + current = parent; + } + + console.warn("Unable to find similar parent element from the next sibling", target, nextSibling); + return target; + } + /** * Sort elements based on layout mode for better scroll position tracking */ @@ -1881,7 +1984,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.updateImageSizes(); // Re-call this as we will change window width/height again requestAnimationFrame(() => { - this.scrollTo(resumeElement); + this.addEmptyPageIfRequired(); // Try add after layout updated on next frame + + // When the user switches pages, there may be a pending scroll that moves to the start or end of the page + // For example, `this.scrollWithinPage(...)` might be triggered when the user presses the prev/next page button + // So, we don't need to do another page scroll here + if (!this.hasDelayedScroll) { + this.scrollTo(resumeElement); + } }); } } @@ -1961,7 +2071,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } scrollTo(partSelector: string, timeout: number = 0) { - const element = this.getElementFromXPath(partSelector); + const element = this.getElementFromXPath(partSelector) as HTMLElement; if (element === null) { if (!environment.production) { @@ -1975,7 +2085,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const writingStyle = this.writingStyle(); if (layout !== BookPageLayoutMode.Default) { - afterFrame(() => this.scrollService.scrollIntoView(element as HTMLElement, {timeout, scrollIntoViewOptions: {'block': 'start', 'inline': 'start'}})); + afterFrame(() => { + // scrollIntoView method will only scroll to the visible area of the element (not including margin) + // so we need to apply scroll-margin to that element to correctly scroll into it + let margin = window.getComputedStyle(element).margin; + if(margin !== '0px') element.style.scrollMargin = margin; + + this.scrollService.scrollIntoView(element, {timeout, scrollIntoViewOptions: {'block': 'start', 'inline': 'start'}}) + }); return; } @@ -2063,6 +2180,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.updateImageSizeTimeout = setTimeout( () => { this.updateImageSizes(); this.injectImageBookmarkIndicators(true); + + // This needs to be checked after the bookmark indicator has been injected or removed + // When switching layout, these indicators may affect the page's total scrollWidth + this.addEmptyPageIfRequired(); }, 200); this.updateSingleImagePageStyles(); @@ -2332,7 +2453,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { redRect.style.top = `${viewport.top}px`; redRect.style.width = `${viewport.width}px`; redRect.style.height = `${viewport.height}px`; - redRect.style.border = '1px solid red'; + redRect.style.outline = '1px solid red'; redRect.style.pointerEvents = 'none'; redRect.style.zIndex = '1000'; redRect.title = `Width: ${viewport.width}px`; @@ -2357,7 +2478,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { greenRect.style.top = `${viewport.top}px`; greenRect.style.width = `${margin}px`; greenRect.style.height = `${viewport.height}px`; - greenRect.style.border = '1px solid green'; + greenRect.style.outline = '1px solid green'; greenRect.style.pointerEvents = 'none'; greenRect.style.zIndex = '1000'; greenRect.title = `Width: ${margin}px`; @@ -2376,7 +2497,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { greenRect.style.top = `${viewport.top}px`; greenRect.style.width = `${margin}px`; greenRect.style.height = `${viewport.height}px`; - greenRect.style.border = '1px solid green'; + greenRect.style.outline = '1px solid green'; greenRect.style.pointerEvents = 'none'; greenRect.style.zIndex = '1000'; greenRect.title = `Width: ${margin}px`; @@ -2437,13 +2558,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return false; } - logSelectedElement() { - const element = this.getElementFromXPath(this.lastSeenScrollPartPath); + logSelectedElement(color='red') { + const element = this.getElementFromXPath(this.lastSeenScrollPartPath) as HTMLElement | null; if (element) { console.log(element); - (element as HTMLElement).style.border = '1px solid red'; + element.style.outline = '1px solid ' + color; setTimeout(() => { - (element as HTMLElement).style.border = ''; + element.style.outline = ''; }, 1_000); } } @@ -2451,8 +2572,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { protected readonly Breakpoint = Breakpoint; protected readonly environment = environment; - protected readonly BookPageLayoutMode = BookPageLayoutMode; - protected readonly WritingStyle = WritingStyle; protected readonly ReadingDirection = ReadingDirection; protected readonly PAGING_DIRECTION = PAGING_DIRECTION; } diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.scss b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.scss index 17e5bc30a..3a83e4f0e 100644 --- a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.scss +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.scss @@ -1,5 +1,5 @@ .clickable:hover, .clickable:focus { - background-color: var(--list-group-hover-bg-color, --primary-color); + background-color: var(--list-group-hover-bg-color, var(--primary-color)); } .collection { diff --git a/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.scss b/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.scss index 0974b1867..1d1c4da61 100644 --- a/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.scss +++ b/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.scss @@ -1,5 +1,5 @@ .clickable:hover, .clickable:focus { - background-color: var(--list-group-hover-bg-color, --primary-color); + background-color: var(--list-group-hover-bg-color, var(--primary-color)); } .pill { diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index d8ffa55b1..21fd2c75e 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -267,6 +267,8 @@ export class CardDetailLayoutComponent - @if (isLoading) { + @if (isLoading && !disableLoadingIndicator()) {
{{t('loading-message')}} diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts index b594bea15..882094c37 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts @@ -7,7 +7,7 @@ import { HostListener, inject, OnDestroy, - OnInit, + OnInit, signal, ViewChild } from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; @@ -110,6 +110,10 @@ export class PdfReaderComponent implements OnInit, OnDestroy { backgroundColor: string = this.themeMap[this.theme].background; fontColor: string = this.themeMap[this.theme].font; + /** + * True if Preferences.DataSaver is true + */ + disableLoadingIndicator = signal(false); isLoading: boolean = true; /** * How much of the current document is loaded @@ -260,6 +264,9 @@ export class PdfReaderComponent implements OnInit, OnDestroy { this.backgroundColor = this.themeMap[this.theme].background; this.fontColor = this.themeMap[this.theme].font; // TODO: Move this to an observable or something + this.disableLoadingIndicator.set(this.user.preferences.dataSaver); + pdfDefaultOptions.disableAutoFetch = this.user.preferences.dataSaver; + this.calcScrollbarNeeded(); this.bookService.getBookInfo(this.chapterId).subscribe(info => { diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts index 58c5a2c8f..e46d004d1 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.ts @@ -1,6 +1,5 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; import {Router} from '@angular/router'; -import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; import {take} from 'rxjs/operators'; import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; @@ -43,7 +42,6 @@ export class ReadingListsComponent implements OnInit { private router = inject(Router); private jumpbarService = inject(JumpbarService); private readonly cdRef = inject(ChangeDetectorRef); - private ngbModal = inject(NgbModal); private titleService = inject(Title); protected readonly WikiLink = WikiLink; diff --git a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html index 1c5735671..b0dd0dbff 100644 --- a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html +++ b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html @@ -24,11 +24,11 @@ @for (opt of options(); track opt.value; let index = $index) {
  • - - + @if (opt.colour) { @let c = opt.colour; diff --git a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts index 34adf5ac1..1e899021c 100644 --- a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts +++ b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts @@ -64,6 +64,10 @@ export interface MultiCheckBoxItem { }) export class SettingMultiCheckBox implements ControlValueAccessor { + /** + * Id to prepend to input id to ensure uniqueness + */ + id = input.required(); /** * Title to display above the checkboxes */ diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.scss b/UI/Web/src/app/settings/_components/settings/settings.component.scss index 909eae7a1..270197872 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.scss +++ b/UI/Web/src/app/settings/_components/settings/settings.component.scss @@ -1,7 +1,7 @@ @use '../../../../theme/variables' as theme; h2 { - color: white; + color: var(--side-nav-header-text-color); font-weight: bold; } diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.scss b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.scss index 4bc133186..a6110a984 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.scss +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.scss @@ -68,7 +68,7 @@ } .side-nav-header { - color: #d5d5d5; + color: var(--pref-side-nav-header-text-color); font-weight: bold; margin-left: 5px; } diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html index 57c3b7581..db5e68442 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html @@ -84,6 +84,18 @@
    +
    + + +
    + +
    +
    +
    +
    +
    diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts index 446e78d23..22da8f0c6 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts @@ -47,6 +47,7 @@ type UserPreferencesForm = FormGroup<{ locale: FormControl, bookReaderHighlightSlots: FormArray>, colorScapeEnabled: FormControl, + dataSaver: FormControl, aniListScrobblingEnabled: FormControl, wantToReadSync: FormControl, @@ -96,9 +97,6 @@ export class ManageUserPreferencesComponent implements OnInit { loading = signal(true); ageRatings = signal([]); libraries = signal([]); - libraryOptions = computed(() => this.libraries().map(l => { - return { label: l.name, value: l.id }; - })); locales: Array = []; @@ -165,6 +163,7 @@ export class ManageUserPreferencesComponent implements OnInit { locale: this.fb.control(pref.locale || 'en'), bookReaderHighlightSlots: this.fb.array(pref.bookReaderHighlightSlots.map(s => this.fb.control(s))), colorScapeEnabled: this.fb.control(pref.colorScapeEnabled), + dataSaver: this.fb.control(pref.dataSaver), aniListScrobblingEnabled: this.fb.control(pref.aniListScrobblingEnabled), wantToReadSync: this.fb.control(pref.wantToReadSync), diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 296d5b86c..e2b97e4d5 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -210,6 +210,8 @@ "highlight-bar-tooltip": "These colors are shared between all books", "colorscape-label": "Use ColorScape", "colorscape-tooltip": "Global toggle to enable/disable the dynamic gradient feature. Will override theme settings", + "data-saver-label": "Data saver", + "data-saver-tooltip": "Minimizes data usage by preventing automatic prefetching (e.g., PDF reader)", "kavitaplus-settings-title": "Kavita+", "anilist-scrobbling-label": "AniList Scrobbling", @@ -782,7 +784,7 @@ "license": { "title": "Kavita+ License", - "kavita+-warning": "Kavita+ is separate from Kavita. If you uninstall Kavita without unsubscribing, you will be charged.", + "kavita+-warning": "Kavita+ is separate from Kavita. Uninstalling without first cancelling your subscription will continue the billing cycle.", "manage": "Manage", "invalid-license-tooltip": "If your subscription has ended, you must email support to get a new subscription created", "check": "Check", diff --git a/UI/Web/src/environments/environment.proxy.ts b/UI/Web/src/environments/environment.proxy.ts index 0bb5c7ab1..295ff021b 100644 --- a/UI/Web/src/environments/environment.proxy.ts +++ b/UI/Web/src/environments/environment.proxy.ts @@ -2,12 +2,13 @@ // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. // The list of file replacements can be found in `angular.json`. -const IP = 'localhost'; - +// All requests to the backend are proxies through the Angular server, we let the browser pick the host +// This comes with the advantage that you don't need to change anything to test on a different device on the +// network. export const environment = { production: false, - apiUrl: 'http://' + IP + ':4200/api/', - hubUrl: 'http://'+ IP + ':4200/hubs/', + apiUrl: '/api/', + hubUrl: '/hubs/', buyLink: 'https://buy.stripe.com/test_9AQ5mi058h1PcIo3cf?prefilled_promo_code=FREETRIAL', manageLink: 'https://billing.stripe.com/p/login/test_14kfZocuh6Tz5ag7ss' }; diff --git a/UI/Web/src/theme/components/_buttons.scss b/UI/Web/src/theme/components/_buttons.scss index 387f39714..b74f3a12b 100644 --- a/UI/Web/src/theme/components/_buttons.scss +++ b/UI/Web/src/theme/components/_buttons.scss @@ -14,10 +14,18 @@ --bs-btn-active-bg: var(--primary-color-dark-shade); --bs-btn-active-border-color: var(--primary-color-dark-shade); - &:hover { + i { + color: var(--btn-primary-text-color); + } + + &:hover, &:focus-visible { color: var(--btn-primary-hover-text-color); background-color: var(--btn-primary-hover-bg-color); border-color: var(--btn-primary-hover-border-color); + + i { + color: var(--btn-primary-hover-text-color); + } } } @@ -30,10 +38,18 @@ background-color: var(--btn-outline-primary-bg-color); border-color: var(--btn-outline-primary-border-color); - &:hover { + i { + color: var(--btn-outline-primary-text-color); + } + + &:hover, &:focus-visible { color: var(--btn-outline-primary-hover-text-color) !important; background-color: var(--btn-outline-primary-hover-bg-color) !important; border-color: var(--btn-outline-primary-hover-border-color) !important; + + i { + color: var(--btn-outline-primary-hover-text-color) !important; + } } } @@ -82,7 +98,11 @@ background-color: var(--btn-secondary-outline-bg-color); border-color: var(--btn-secondary-outline-border-color); - &:hover { + i { + color: var(--btn-secondary-outline-text-color); + } + + &:hover, &:focus-visible { --bs-btn-color: var(--btn-secondary-outline-hover-text-color); --bs-btn-hover-bg: var(-btn-secondary-outline-hover-bg-color); --bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color); @@ -94,6 +114,10 @@ box-shadow: inset 0px -2px 0px 0px var(--btn-secondary-outline-text-color); border-bottom-left-radius: 0; border-bottom-right-radius: 0; + + i { + color: var(--btn-secondary-outline-hover-text-color); + } } } @@ -103,10 +127,18 @@ background-color: var(--btn-danger-outline-bg-color); border-color: var(--btn-danger-outline-border-color); - &:hover { + i { + color: var(--btn-danger-outline-text-color); + } + + &:hover, &:focus-visible { color: var(--btn-danger-outline-hover-text-color); background-color: var(--btn-danger-outline-hover-bg-color); border-color: var(--btn-danger-outline-hover-border-color); + + i { + color: var(--btn-danger-outline-hover-text-color); + } } } @@ -132,6 +164,9 @@ background-color: var(--btn-disabled-bg-color); color: var(--btn-disabled-text-color); border-color: var(--btn-disabled-border-color); + i { + color: var(--btn-disabled-text-color); + } } button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :disabled { @@ -144,14 +179,14 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di color: var(--body-text-color) !important; } - &:hover, &:focus { + &:hover, &:focus, &:focus-visible { color: var(--primary-color); } } .btn:focus, .btn:active, .btn:active:focus { - box-shadow: 0 0 0 0 var(---btn-focus-boxshadow-color) !important; + box-shadow: 0 0 0 0 var(--btn-focus-boxshadow-color) !important; } @@ -160,11 +195,15 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di color: var(--body-text-color); border: none; + i { + color: var(--body-text-color); + } + &:disabled { --bs-btn-disabled-bg: transparent; } - &:hover, &:focus { + &:hover, &:focus, &:focus-visible { color: var(--body-text-color); border: none; } @@ -178,14 +217,26 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di .btn-primary-text { color: var(--btn-primary-text-text-color); + + i { + color: var(--btn-primary-text-text-color); + } } .btn-secondary-text { color: var(--btn-secondary-text-text-color); + + i { + color: var(--btn-secondary-text-text-color); + } } .btn-danger-text { color: var(--btn-danger-text-text-color); + + i { + color: var(--btn-danger-text-text-color); + } } @@ -194,6 +245,10 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di color: var(--btn-secondary-text-color); background-color: var(--btn-secondary-bg-color); border-color: var(--btn-secondary-border-color); + + i { + color: var(--btn-secondary-text-color); + } } .btn.btn-secondary.alt { @@ -205,15 +260,17 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di font-weight: var(--btn-secondary-font-weight); } - &:focus { + &:focus, &:focus-visible { background-color: var(--btn-alt-focus-bg-color); box-shadow: 0 0 0 0.05rem var(--btn-alt-focus-boxshadow-color); font-weight: var(--btn-secondary-font-weight); } } -button i.fa { - color: var(--btn-fa-icon-color); +button { + i.fa, i.fa-regular { + color: var(--btn-fa-icon-color); + } } .btn-check:focus + .btn, .btn:focus { @@ -230,7 +287,6 @@ button i.fa { border: none; padding: 0; margin: 0; - font: inherit; cursor: pointer; outline: inherit; } @@ -239,10 +295,3 @@ button i.fa { outline: none; } -// -//.btn-primary .btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show { -// --bs-btn-active-bg: var(--primary-color-dark-shade); -// --bs-btn-active-border-color: var(--primary-color-dark-shade); -//} - - diff --git a/UI/Web/src/theme/components/_card.scss b/UI/Web/src/theme/components/_card.scss index 5ec224564..6b44a76e8 100644 --- a/UI/Web/src/theme/components/_card.scss +++ b/UI/Web/src/theme/components/_card.scss @@ -38,6 +38,10 @@ $image-height: 232.91px; $image-filter-height: 160px; $image-width: 160px; +.card-title { + --bs-card-title-color: var(--card-title-text-color); +} + .card-item-container { .card { max-width: $image-width; @@ -76,12 +80,19 @@ $image-width: 160px; height: $image-filter-height; } } - } + & .card-title { + color: var(--card-overlay-text-color); + } + + & + .card-body { + color: var(--card-overlay-text-color); + } + } .card-body { padding: 0 5px !important; - background-color: rgba(0,0,0,0.7); + background-color: var(--card-body-bg-color); border-width: var(--card-border-width); border-style: var(--card-border-style); border-color: var(--card-border-color); diff --git a/UI/Web/src/theme/components/_input.scss b/UI/Web/src/theme/components/_input.scss index 503be879c..64b9dda9d 100644 --- a/UI/Web/src/theme/components/_input.scss +++ b/UI/Web/src/theme/components/_input.scss @@ -3,14 +3,16 @@ input:not([type="range"]), .form-control { color: var(--input-text-color); border-color: var(--input-border-color); - &:focus { + &:focus:not(:checked) { border-color: var(--input-focused-border-color); background-color: var(--input-bg-color); color: var(--input-text-color); box-shadow: 0 0 0 .25rem var(--input-focus-boxshadow-color); } - &:read-only { + // Checkboxes are selected by the :read-only pseudo-class, even when they're editable + // See https://developer.mozilla.org/en-US/docs/Web/CSS/:read-only + &:read-only:not([type="checkbox"]) { background-color: var(--input-bg-readonly-color); cursor: initial; } diff --git a/UI/Web/src/theme/components/_selects.scss b/UI/Web/src/theme/components/_selects.scss index 6e6b1fe16..8d329065f 100644 --- a/UI/Web/src/theme/components/_selects.scss +++ b/UI/Web/src/theme/components/_selects.scss @@ -14,11 +14,6 @@ box-shadow: 0 0 0 0.25rem var(--input-focus-boxshadow-color); } - &:read-only { - background-color: var(--input-bg-readonly-color); - cursor: initial; - } - &:disabled { cursor: not-allowed; pointer-events: none; diff --git a/UI/Web/src/theme/components/_sidenav.scss b/UI/Web/src/theme/components/_sidenav.scss index 33faf7d82..630acd3f6 100644 --- a/UI/Web/src/theme/components/_sidenav.scss +++ b/UI/Web/src/theme/components/_sidenav.scss @@ -114,8 +114,7 @@ } .active-highlight { - background-color: #2f2f2f; - background-color: rgb(255 255 255 / 9%); + background-color: var(--side-nav-item-color); width: 0.25rem; height: 100%; position: absolute; @@ -203,7 +202,7 @@ padding-left: 1.125rem; .side-nav-header { - color: #ffffff; + color: var(--side-nav-header-text-color); font-size: 1rem; margin-left: unset; @@ -227,7 +226,7 @@ text-align: unset; margin-left: 0.75rem; font-size: 0.9rem; - color: #999999; + color: var(--side-nav-text-color); div { display: flex; @@ -238,6 +237,11 @@ } } + &:hover { + .side-nav-text { + color: var(--side-nav-hover-text-color); + } + } .card-actions { display: none; } diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index 6096caab9..371ad04ad 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -244,7 +244,8 @@ --side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%); --side-nav-hover-text-color: white; --side-nav-hover-bg-color: black; - --side-nav-text-color: hsla(0,0%,100%,.85); + --side-nav-text-color: hsla(0, 0%, 100%, .85); + --side-nav-header-text-color: white; --side-nav-border-radius: 3px; --side-nav-border: none; --side-nav-border-closed: none; @@ -256,8 +257,10 @@ --side-nav-item-active-text-color: #fff; --side-nav-active-bg-color: transparent; --side-nav-overlay-color: var(--elevation-layer11-dark); + --side-nav-item-color: rgb(255 255 255 / 9%); --side-nav-item-closed-color: var(--elevation-layer10); --side-nav-item-closed-hover-color: white; + --pref-side-nav-header-text-color: #d5d5d5; /* List items */ --list-group-item-text-color: var(--body-text-color); @@ -353,6 +356,11 @@ --card-overlay-bg-color: rgba(0, 0, 0, 0); --card-overlay-hover-bg-color: rgba(30,30,30,.6); --card-progress-triangle-size: 28px; + --card-body-bg-color: rgba(0,0,0,0.7); + --card-title-text-color: var(--card-text-color); + --card-overlay-text-color: var(--card-text-color); + --card-hover-text-color: var(--card-text-color); + --card-hover-bg-color: #3a3a3a; /* Slider */ --slider-text-color: white;