diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 000000000..1876ac55a --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,15 @@ +# Path to sources +sonar.sources=. +sonar.exclusions=API.Benchmark +#sonar.inclusions= + +# Path to tests +sonar.tests=API.Tests +#sonar.test.exclusions= +#sonar.test.inclusions= + +# Source encoding +sonar.sourceEncoding=UTF-8 + +# Exclusions for copy-paste detection +#sonar.cpd.exclusions= diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 624cef936..052226f56 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -9,8 +9,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/Parsing/ComicParsingTests.cs b/API.Tests/Parsing/ComicParsingTests.cs index 1d0f4ae69..4bb2948b1 100644 --- a/API.Tests/Parsing/ComicParsingTests.cs +++ b/API.Tests/Parsing/ComicParsingTests.cs @@ -78,6 +78,8 @@ public class ComicParsingTests [InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")] [InlineData("Kebab Том 1 Глава 1", "Kebab")] [InlineData("Манга Глава 1", "Манга")] + [InlineData("ReZero รีเซทชีวิต ฝ่าวิกฤตต่างโลก เล่ม 1", "ReZero รีเซทชีวิต ฝ่าวิกฤตต่างโลก")] + [InlineData("SKY WORLD สกายเวิลด์ เล่มที่ 1", "SKY WORLD สกายเวิลด์")] public void ParseComicSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(filename)); @@ -129,6 +131,9 @@ public class ComicParsingTests // Russian Tests [InlineData("Kebab Том 1 Глава 3", "1")] [InlineData("Манга Глава 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("ย้อนเวลากลับมาร้าย เล่ม 1", "1")] + [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "1")] + [InlineData("วิวาห์รัก เดิมพันชีวิต ตอนที่ 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] public void ParseComicVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(filename)); @@ -178,6 +183,9 @@ public class ComicParsingTests [InlineData("Манга Глава 2", "2")] [InlineData("Манга 2 Глава", "2")] [InlineData("Манга Том 1 2 Глава", "2")] + [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")] + [InlineData("Max Level Returner ตอนที่ 5", "5")] + [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")] public void ParseComicChapterTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(filename)); diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index df09c0ebb..446c9e782 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -207,6 +207,9 @@ public class MangaParsingTests [InlineData("test 2 years 1화", "test 2 years")] [InlineData("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.30 Omake", "Nagasarete Airantou")] [InlineData("Cynthia The Mission - c000 - c006 (v06)", "Cynthia The Mission")] + [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1", "เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท")] + [InlineData("Max Level Returner เล่มที่ 5", "Max Level Returner")] + [InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); @@ -296,6 +299,9 @@ public class MangaParsingTests [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98", "90-98")] [InlineData("Historys Strongest Disciple Kenichi c01-c04", "1-4")] [InlineData("Adabana c00-02", "0-2")] + [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")] + [InlineData("Max Level Returner ตอนที่ 5", "5")] + [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename)); diff --git a/API/API.csproj b/API/API.csproj index a3eb80a22..c045d6981 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -69,7 +69,7 @@ - + @@ -81,8 +81,8 @@ - - + + @@ -95,14 +95,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 81b3ea6fe..7fcb0a95c 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.DTOs.Uploads; +using API.Entities.Enums; using API.Extensions; using API.Services; using API.SignalR; @@ -98,6 +99,7 @@ public class UploadController : BaseApiController try { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id); + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}"); @@ -225,17 +227,14 @@ public class UploadController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-reading-list-save")); } - private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0) + private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename) { - var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs; - if (thumbnailSize > 0) - { - return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, - filename, encodeFormat, thumbnailSize); - } + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var encodeFormat = settings.EncodeMediaAs; + var coverImageSize = settings.CoverImageSize; return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, - filename, encodeFormat); + filename, encodeFormat, coverImageSize.GetDimensions().Width); } /// @@ -326,8 +325,7 @@ public class UploadController : BaseApiController try { var filePath = await CreateThumbnail(uploadFileDto, - $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", - ImageService.LibraryThumbnailWidth); + $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index fdb6baa5d..e6c5013e5 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -118,6 +118,12 @@ public class UsersController : BaseApiController existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; + + existingPreferences.PdfTheme = preferencesDto.PdfTheme; + existingPreferences.PdfLayoutMode = preferencesDto.PdfLayoutMode; + existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode; + existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode; + if (_localizationService.GetLocales().Contains(preferencesDto.Locale)) { existingPreferences.Locale = preferencesDto.Locale; diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 41160e362..1221c73e5 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -152,4 +152,25 @@ public class UserPreferencesDto /// [Required] public string Locale { get; set; } + + /// + /// PDF Reader: Theme of the Reader + /// + [Required] + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + /// + /// PDF Reader: Scroll mode of the reader + /// + [Required] + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + /// + /// PDF Reader: Layout Mode of the reader + /// + [Required] + public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple; + /// + /// PDF Reader: Spread Mode of the reader + /// + [Required] + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; } diff --git a/API/Data/Migrations/20240328130057_PdfSettings.Designer.cs b/API/Data/Migrations/20240328130057_PdfSettings.Designer.cs new file mode 100644 index 000000000..cba2d534f --- /dev/null +++ b/API/Data/Migrations/20240328130057_PdfSettings.Designer.cs @@ -0,0 +1,2916 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20240328130057_PdfSettings")] + partial class PdfSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.3"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("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("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("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfLayoutMode") + .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("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("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.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("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("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("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("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("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + 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("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("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.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.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("AverageScore") + .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.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("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + 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.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("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("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.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("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("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("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("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("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("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("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.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.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.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.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.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("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("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("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + 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/20240328130057_PdfSettings.cs b/API/Data/Migrations/20240328130057_PdfSettings.cs new file mode 100644 index 000000000..699875968 --- /dev/null +++ b/API/Data/Migrations/20240328130057_PdfSettings.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PdfSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PdfLayoutMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PdfScrollMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PdfSpreadMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PdfTheme", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PdfLayoutMode", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "PdfScrollMode", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "PdfSpreadMode", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "PdfTheme", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index f6be4e431..a67d3819e 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -355,6 +355,18 @@ namespace API.Data.Migrations b.Property("PageSplitOption") .HasColumnType("INTEGER"); + b.Property("PdfLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + b.Property("PromptForDownloadSize") .HasColumnType("INTEGER"); diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 640ecc1ea..09defda3f 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -7,6 +7,9 @@ namespace API.Entities; public class AppUserPreferences { public int Id { get; set; } + + #region MangaReader + /// /// Manga Reader Option: What direction should the next/prev page buttons go /// @@ -51,6 +54,11 @@ public class AppUserPreferences /// Manga Reader Option: Should swiping trigger pagination /// public bool SwipeToPaginate { get; set; } + + #endregion + + #region EpubReader + /// /// Book Reader Option: Override extra Margin /// @@ -75,17 +83,11 @@ public class AppUserPreferences /// Book Reader Option: What direction should the next/prev page buttons go /// public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; - /// /// Book Reader Option: Defines the writing styles vertical/horizontal /// public WritingStyle BookReaderWritingStyle { get; set; } = WritingStyle.Horizontal; /// - /// UI Site Global Setting: The UI theme the user should use. - /// - /// Should default to Dark - public required SiteTheme Theme { get; set; } = Seed.DefaultThemes[0]; - /// /// Book Reader Option: The color theme to decorate the book contents /// /// Should default to Dark @@ -101,6 +103,37 @@ public class AppUserPreferences /// /// Defaults to false public bool BookReaderImmersiveMode { get; set; } = false; + #endregion + + #region PdfReader + + /// + /// PDF Reader: Theme of the Reader + /// + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + /// + /// PDF Reader: Scroll mode of the reader + /// + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + /// + /// PDF Reader: Layout Mode of the reader + /// + public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple; + /// + /// PDF Reader: Spread Mode of the reader + /// + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; + + + #endregion + + #region Global + + /// + /// UI Site Global Setting: The UI theme the user should use. + /// + /// Should default to Dark + public required SiteTheme Theme { get; set; } = Seed.DefaultThemes[0]; /// /// Global Site Option: If the UI should layout items as Cards or List items /// @@ -132,6 +165,8 @@ public class AppUserPreferences /// public string Locale { get; set; } + #endregion + public AppUser AppUser { get; set; } = null!; public int AppUserId { get; set; } } diff --git a/API/Entities/Enums/UserPreferences/PdfBookMode.cs b/API/Entities/Enums/UserPreferences/PdfBookMode.cs new file mode 100644 index 000000000..5946e17c5 --- /dev/null +++ b/API/Entities/Enums/UserPreferences/PdfBookMode.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.UserPreferences; + +public enum PdfLayoutMode +{ + /// + /// Multiple pages render stacked (normal pdf experience) + /// + [Description("Multiple")] + Multiple = 0, + // [Description("Single")] + // Single = 1, + /// + /// A book mode where page turns are animated and layout is side-by-side + /// + [Description("Book")] + Book = 2, + // [Description("Infinite Scroll")] + // InfiniteScroll = 3 +} diff --git a/API/Entities/Enums/UserPreferences/PdfScrollMode.cs b/API/Entities/Enums/UserPreferences/PdfScrollMode.cs new file mode 100644 index 000000000..93cc5bd2e --- /dev/null +++ b/API/Entities/Enums/UserPreferences/PdfScrollMode.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.UserPreferences; + +/// +/// Enum values match PdfViewer's enums +/// +public enum PdfScrollMode +{ + [Description("Vertical")] + Vertical = 0, + [Description("Horizontal")] + Horizontal = 1, + // [Description("Wrapped")] + // Wrapped = 2, + /// + /// Single page view (tap to pagninate) + /// + [Description("Page")] + Page = 3 +} diff --git a/API/Entities/Enums/UserPreferences/PdfSpreadMode.cs b/API/Entities/Enums/UserPreferences/PdfSpreadMode.cs new file mode 100644 index 000000000..412239d4a --- /dev/null +++ b/API/Entities/Enums/UserPreferences/PdfSpreadMode.cs @@ -0,0 +1,13 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.UserPreferences; + +public enum PdfSpreadMode +{ + [Description("None")] + None = 0, + [Description("Odd")] + Odd = 1, + [Description("Even")] + Even = 2 +} diff --git a/API/Entities/Enums/UserPreferences/PdfTheme.cs b/API/Entities/Enums/UserPreferences/PdfTheme.cs new file mode 100644 index 000000000..0efe1dfde --- /dev/null +++ b/API/Entities/Enums/UserPreferences/PdfTheme.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.UserPreferences; + +public enum PdfTheme +{ + [Description("Dark")] + Dark = 0, + [Description("Light")] + Light = 1 +} diff --git a/API/Extensions/ChapterListExtensions.cs b/API/Extensions/ChapterListExtensions.cs index a1dc510bb..5456a6e16 100644 --- a/API/Extensions/ChapterListExtensions.cs +++ b/API/Extensions/ChapterListExtensions.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using API.Entities; using API.Helpers; +using API.Helpers.Builders; using API.Services.Tasks.Scanner.Parser; namespace API.Extensions; @@ -24,6 +25,7 @@ public static class ChapterListExtensions /// Gets a single chapter (or null if doesn't exist) where Range matches the info.Chapters property. If the info /// is then, the filename is used to search against Range or if filename exists within Files of said Chapter. /// + /// This uses GetNumberTitle() to calculate the Range to compare against the info.Chapters /// /// /// @@ -31,9 +33,12 @@ public static class ChapterListExtensions { var normalizedPath = Parser.NormalizePath(info.FullFilePath); var specialTreatment = info.IsSpecialInfo(); + // NOTE: This can fail to find the chapter when Range is "1.0" as the chapter will store it as "1" hence why we need to emulate a Chapter + var fakeChapter = new ChapterBuilder(info.Chapters, info.Chapters).Build(); + fakeChapter.UpdateFrom(info); return specialTreatment ? chapters.FirstOrDefault(c => c.Range == Parser.RemoveExtensionIfSupported(info.Filename) || c.Files.Select(f => Parser.NormalizePath(f.FilePath)).Contains(normalizedPath)) - : chapters.FirstOrDefault(c => c.Range == info.Chapters); + : chapters.FirstOrDefault(c => c.Range == fakeChapter.GetNumberTitle()); } /// diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index be10abb9e..52f4f254a 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -164,7 +164,9 @@ public static class IncludesExtensions if (includeFlags.HasFlag(AppUserIncludes.UserPreferences)) { - query = query.Include(u => u.UserPreferences); + query = query + .Include(u => u.UserPreferences) + .ThenInclude(p => p.Theme); } if (includeFlags.HasFlag(AppUserIncludes.WantToRead)) diff --git a/API/Helpers/Builders/ChapterBuilder.cs b/API/Helpers/Builders/ChapterBuilder.cs index 6b0621e57..de4bf094f 100644 --- a/API/Helpers/Builders/ChapterBuilder.cs +++ b/API/Helpers/Builders/ChapterBuilder.cs @@ -36,7 +36,7 @@ public class ChapterBuilder : IEntityBuilder var specialTitle = specialTreatment ? Parser.RemoveExtensionIfSupported(info.Filename) : info.Chapters; var builder = new ChapterBuilder(Parser.DefaultChapter); - return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters)) + return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters)!) .WithRange(specialTreatment ? info.Filename : info.Chapters) .WithTitle((specialTreatment && info.Format == MangaFormat.Epub) ? info.Title diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index b9c76df1f..ceb609a70 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -382,7 +382,7 @@ public class BookService : IBookService } } - var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link"); + var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link[@href]"); if (styleNodes != null) { foreach (var styleLinks in styleNodes) diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 00dbe135c..4d5f17cb9 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -148,14 +148,14 @@ public class LibraryWatcher : ILibraryWatcher private void OnChanged(object sender, FileSystemEventArgs e) { - _logger.LogDebug("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType); + _logger.LogTrace("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType); if (e.ChangeType != WatcherChangeTypes.Changed) return; BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)))); } private void OnCreated(object sender, FileSystemEventArgs e) { - _logger.LogDebug("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name); + _logger.LogTrace("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name); BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name))); } @@ -167,7 +167,7 @@ public class LibraryWatcher : ILibraryWatcher private void OnDeleted(object sender, FileSystemEventArgs e) { var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)); if (!isDirectory) return; - _logger.LogDebug("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name); + _logger.LogTrace("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name); BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, true)); } @@ -285,10 +285,10 @@ public class LibraryWatcher : ILibraryWatcher var rootFolder = _directoryService.GetFoldersTillRoot(libraryFolder, filePath).ToList(); _logger.LogTrace("[LibraryWatcher] Root Folders: {RootFolders}", rootFolder); - if (!rootFolder.Any()) return string.Empty; + if (rootFolder.Count == 0) return string.Empty; // Select the first folder and join with library folder, this should give us the folder to scan. - return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); + return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); } diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index 4e7318caf..75227399a 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -115,13 +115,21 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau { info.LocalizedSeries = info.ComicInfo.LocalizedSeries.Trim(); } + + if (!string.IsNullOrEmpty(info.ComicInfo.Format) && Parser.HasComicInfoSpecial(info.ComicInfo.Format)) + { + info.IsSpecial = true; + info.Chapters = Parser.DefaultChapter; + info.Volumes = Parser.SpecialVolume; + } + if (!string.IsNullOrEmpty(info.ComicInfo.Number)) { info.Chapters = info.ComicInfo.Number; if (info.IsSpecial && Parser.DefaultChapter != info.Chapters) { info.IsSpecial = false; - info.Volumes = $"{Parser.SpecialVolumeNumber}"; + info.Volumes = Parser.SpecialVolume; } } @@ -130,6 +138,7 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau { info.SeriesSort = info.ComicInfo.TitleSort.Trim(); } + } public abstract bool IsApplicable(string filePath, LibraryType type); diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 09ee87bab..8c5aaaf84 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -121,6 +121,10 @@ public static class Parser private static readonly Regex[] MangaVolumeRegex = new[] { + // Thai Volume: เล่ม n -> Volume n + new Regex( + @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Dance in the Vampire Bund v16-17 new Regex( @"(?.*)(\b|_)v(?\d+-?\d+)( |_)", @@ -194,6 +198,10 @@ public static class Parser private static readonly Regex[] MangaSeriesRegex = new[] { + // Thai Volume: เล่ม n -> Volume n + new Regex( + @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Russian Volume: Том n -> Volume n, Тома n -> Volume new Regex( @"(?.+?)Том(а?)(\.?)(\s|_)?(?\d+(?:(\-)\d+)?)", @@ -368,6 +376,10 @@ public static class Parser private static readonly Regex[] ComicSeriesRegex = new[] { + // Thai Volume: เล่ม n -> Volume n + new Regex( + @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Russian Volume: Том n -> Volume n, Тома n -> Volume new Regex( @"(?.+?)Том(а?)(\.?)(\s|_)?(?\d+(?:(\-)\d+)?)", @@ -456,6 +468,10 @@ public static class Parser private static readonly Regex[] ComicVolumeRegex = new[] { + // Thai Volume: เล่ม n -> Volume n + new Regex( + @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( @"^(?.+?)(?: |_)(t|v)(?" + NumberRange + @")", @@ -492,6 +508,10 @@ public static class Parser private static readonly Regex[] ComicChapterRegex = new[] { + // Thai Volume: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n + new Regex( + @"(บทที่|ตอนที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Batman & Wildcat (1 of 3) new Regex( @"(?.*(\d{4})?)( |_)(?:\((?\d+) of \d+)", @@ -557,6 +577,10 @@ public static class Parser private static readonly Regex[] MangaChapterRegex = new[] { + // Thai Chapter: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n, เล่ม n -> Volume n, เล่มที่ n -> Volume n + new Regex( + @"(?((เล่ม|เล่มที่))?(\s|_)?\.?\d+)(\s|_)(บทที่|ตอนที่)\.?(\s|_)?(?\d+)", + MatchOptions, RegexTimeout), // Historys Strongest Disciple Kenichi_v11_c90-98.zip, ...c90.5-100.5 new Regex( @"(\b|_)(c|ch)(\.?\s?)(?(\d+(\.\d)?)(-c?\d+(\.\d)?)?)", diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 8f9a58088..e52ebeb49 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -701,7 +701,8 @@ public class ProcessSeries : IProcessSeries { if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter)) { - _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", + existingChapter.Range, volume.Name, parsedInfos[0].Series); volume.Chapters.Remove(existingChapter); } else diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d71bfde2..d3735253f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,15 +21,18 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit 1. Fork Kavita 2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository-from-github) 3. Install the required Node Packages - - cd Kavita/UI/Web + - `cd Kavita/UI/Web` - `npm install` - `npm install -g @angular/cli` - - `npm run cache-locale-prime` (only do this once to generate the locale file) -4. Start angular server `ng serve` -5. Build the project in Visual Studio/Rider, Setting startup project to `API` -6. Debug the project in Visual Studio/Rider -7. Open http://localhost:4200 -8. (Deployment only) Run build.sh and pass the Runtime Identifier for your OS or just build.sh for all supported RIDs. +5. Start the frontend + - `npm run start` +6. Build the project in Visual Studio/Rider, Setting startup project to `API` +7. Debug the project in Visual Studio/Rider +8. Open http://localhost:4200 +9. (Deployment only) Run build.sh and pass the Runtime Identifier for your OS or just build.sh for all supported RIDs. + +### Debugging on Device ### +- Update `IP` constant in `Web/UI/src/environments/environment.ts` to your dev machine's ip instead of `localhost`. ### Contributing Code ### diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index e1a6e09c5..c19c1ee53 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -15,7 +15,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/README.md b/UI/Web/README.md index 1667baaf6..c8d1a002f 100644 --- a/UI/Web/README.md +++ b/UI/Web/README.md @@ -4,7 +4,7 @@ This project was generated with [Angular CLI](https://github.com/angular/angular ## Development server -Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. +Run `npm run start` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. Your backend must be served on port 5000. ## Code scaffolding @@ -25,10 +25,11 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github. Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e tests. -## Connecting to your dev server via your phone +## Connecting to your dev server via your phone or any other compatible client on local network -ng serve --host 0.0.0.0 -and update environment.ts to your local ip. +Update `IP` constant in `src/environments/environment.ts` to your dev machine's ip instead of `localhost`. + +Run `npm run start` ## Notes: - injected services should be at the top of the file diff --git a/UI/Web/hash-localization.js b/UI/Web/hash-localization.js index 547b5af0d..542ae5127 100644 --- a/UI/Web/hash-localization.js +++ b/UI/Web/hash-localization.js @@ -14,6 +14,15 @@ function generateChecksum(str, algorithm, encoding) { const result = {}; +// Generate directory if it doesn't exist +const distFolderPath = './dist/'; +const browserFolderPath = './dist/browser/'; +if (!fs.existsSync(browserFolderPath)) { + console.log('Creating ./dist/browser folder'); + fs.mkdirSync(distFolderPath, 0o744); + fs.mkdirSync(browserFolderPath, 0o744); +} + // Remove file if it exists const cacheBustingFilePath = './i18n-cache-busting.json'; if (fs.existsSync(cacheBustingFilePath)) { diff --git a/UI/Web/package.json b/UI/Web/package.json index 4531609c6..079ef792d 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -3,7 +3,7 @@ "version": "0.7.12.1", "scripts": { "ng": "ng", - "start": "npm run cache-locale && ng serve", + "start": "npm run cache-locale && ng serve --host 0.0.0.0", "build": "npm run cache-locale && ng build", "minify-langs": "node minify-json.js", "cache-locale": "node hash-localization.js", diff --git a/UI/Web/src/app/_models/preferences/pdf-layout-mode.ts b/UI/Web/src/app/_models/preferences/pdf-layout-mode.ts new file mode 100644 index 000000000..53a54a851 --- /dev/null +++ b/UI/Web/src/app/_models/preferences/pdf-layout-mode.ts @@ -0,0 +1,6 @@ +export enum PdfLayoutMode { + Multiple = 0, + Single = 1, + Book = 2, + InfiniteScroll = 3 +} diff --git a/UI/Web/src/app/_models/preferences/pdf-scroll-mode.ts b/UI/Web/src/app/_models/preferences/pdf-scroll-mode.ts new file mode 100644 index 000000000..2a122590d --- /dev/null +++ b/UI/Web/src/app/_models/preferences/pdf-scroll-mode.ts @@ -0,0 +1,6 @@ +export enum PdfScrollMode { + Vertical = 0, + Horizontal = 1, + Wrapped = 2, + Page = 3 +} diff --git a/UI/Web/src/app/_models/preferences/pdf-spread-mode.ts b/UI/Web/src/app/_models/preferences/pdf-spread-mode.ts new file mode 100644 index 000000000..7bd437add --- /dev/null +++ b/UI/Web/src/app/_models/preferences/pdf-spread-mode.ts @@ -0,0 +1,5 @@ +export enum PdfSpreadMode { + None = 0, + Odd = 1, + Even = 2 +} diff --git a/UI/Web/src/app/_models/preferences/pdf-theme.ts b/UI/Web/src/app/_models/preferences/pdf-theme.ts new file mode 100644 index 000000000..b3ecc1796 --- /dev/null +++ b/UI/Web/src/app/_models/preferences/pdf-theme.ts @@ -0,0 +1,4 @@ +export enum PdfTheme{ + Dark = 0, + Light = 1 +} diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 83b7907a8..4ccd60d19 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -8,6 +8,10 @@ import { ReadingDirection } from './reading-direction'; import { ScalingOption } from './scaling-option'; import { SiteTheme } from './site-theme'; import {WritingStyle} from "./writing-style"; +import {PdfTheme} from "./pdf-theme"; +import {PdfScrollMode} from "./pdf-scroll-mode"; +import {PdfLayoutMode} from "./pdf-layout-mode"; +import {PdfSpreadMode} from "./pdf-spread-mode"; export interface Preferences { // Manga Reader @@ -34,6 +38,12 @@ export interface Preferences { bookReaderLayoutMode: BookPageLayoutMode; bookReaderImmersiveMode: boolean; + // PDF Reader + pdfTheme: PdfTheme; + pdfScrollMode: PdfScrollMode; + pdfLayoutMode: PdfLayoutMode; + pdfSpreadMode: PdfSpreadMode; + // Global theme: SiteTheme; globalPageLayoutMode: PageLayoutMode; @@ -50,6 +60,10 @@ export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horiz export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}]; export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}]; export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}]; -export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // , {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} +export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}]; export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}]; +export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}]; +export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; +export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; +export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index a0ed0e20a..34551b7a3 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -52,6 +52,8 @@ export class AccountService { */ private refreshTokenTimeout: ReturnType | undefined; + private isOnline: boolean = true; + constructor(private httpClient: HttpClient, private router: Router, private messageHub: MessageHubService, private themeService: ThemeService) { messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate), @@ -59,6 +61,15 @@ export class AccountService { filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username), switchMap(() => this.refreshAccount())) .subscribe(() => {}); + + window.addEventListener("offline", (e) => { + this.isOnline = false; + }); + + window.addEventListener("online", (e) => { + this.isOnline = true; + this.refreshToken().subscribe(); + }); } hasAdminRole(user: User) { @@ -143,6 +154,7 @@ export class AccountService { localStorage.setItem(this.userKey, JSON.stringify(user)); localStorage.setItem(AccountService.lastLoginKey, user.username); + if (user.preferences && user.preferences.theme) { this.themeService.setTheme(user.preferences.theme.name); } else { @@ -329,7 +341,7 @@ export class AccountService { private refreshToken() { - if (this.currentUser === null || this.currentUser === undefined) return of(); + if (this.currentUser === null || this.currentUser === undefined || !this.isOnline) return of(); return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { if (this.currentUser) { diff --git a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html index c76ddacef..773817c90 100644 --- a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html +++ b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html @@ -2,18 +2,25 @@
diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.html b/UI/Web/src/app/_single-module/review-card/review-card.component.html index e1235070f..d2159033a 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.html +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.html @@ -3,10 +3,12 @@
-
- - {{t('your-review')}} -
+ @if (isMyReview) { +
+ + {{t('your-review')}} +
+ }
@@ -21,17 +23,19 @@
diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.ts b/UI/Web/src/app/_single-module/review-card/review-card.component.ts index 7a0b29de2..8b791afb3 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.ts +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.ts @@ -28,7 +28,7 @@ import {ScrobbleProvider} from "../../_services/scrobbling.service"; @Component({ selector: 'app-review-card', standalone: true, - imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe, TranslocoDirective], + imports: [ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe, TranslocoDirective], templateUrl: './review-card.component.html', styleUrls: ['./review-card.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/_single-module/review-card/user-review.ts b/UI/Web/src/app/_single-module/review-card/user-review.ts index f735d9548..1b5771463 100644 --- a/UI/Web/src/app/_single-module/review-card/user-review.ts +++ b/UI/Web/src/app/_single-module/review-card/user-review.ts @@ -9,6 +9,6 @@ export interface UserReview { tagline?: string; isExternal: boolean; bodyJustText?: string; - externalUrl?: string; + siteUrl?: string; provider: ScrobbleProvider; } diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html index 201044913..2c877693e 100644 --- a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html +++ b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html @@ -13,7 +13,7 @@ @for(rowForm of items.controls; track rowForm; let idx = $index) { - {{progressEvents[idx].userName}} + {{progressEvents[idx].userName | sentenceCase}} @if(editMode[idx]) { diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts index d07283065..8e497ef4e 100644 --- a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts +++ b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts @@ -7,6 +7,7 @@ import {FullProgress} from "../../_models/readers/full-progress"; import {ReaderService} from "../../_services/reader.service"; import {TranslocoDirective} from "@ngneat/transloco"; import {FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; +import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; @Component({ selector: 'app-edit-chapter-progress', @@ -18,7 +19,8 @@ import {FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from TitleCasePipe, UtcToLocalTimePipe, TranslocoDirective, - ReactiveFormsModule + ReactiveFormsModule, + SentenceCasePipe ], templateUrl: './edit-chapter-progress.component.html', styleUrl: './edit-chapter-progress.component.scss', diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html index 1b372f268..d6447b61e 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html @@ -33,25 +33,54 @@ [backgroundColor]="backgroundColor" [customToolbar]="multiToolbar" [language]="user.preferences.locale" + [(scrollMode)]="scrollMode" + [pageViewMode]="pageLayoutMode" + [spread]="spreadMode" (pageChange)="saveProgress()" (pdfLoadingStarts)="updateLoading(true)" (pdfLoaded)="updateLoading(false)" (progress)="updateLoadProgress($event)" + (zoomChange)="calcScrollbarNeeded()" > + @if (scrollMode === ScrollModeType.page) { +
+
+ } +
+ + @if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) { + + } + + + +
- + @if (utilityService.getActiveBreakpoint() > Breakpoint.Tablet) { + + } +
@@ -59,42 +88,66 @@ - + @if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) { + + } - - + + + + - - + +
- - - - - - - -
diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.scss b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.scss index b24ce9eaa..d1de24e33 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.scss +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.scss @@ -2,6 +2,9 @@ font-size: 19px; } +.btn-icon { + border: none; +} .book-title { margin: 8px 0 4px !important; @@ -24,3 +27,76 @@ // NOTE: We have to override due to theme variables not being available background-color: #3B9E76; } + +$pagination-color: transparent; +$pagination-opacity: 0; + +//$pagination-color: red; +//$pagination-opacity: 0.7; +$action-bar-height: 36px; + +// Tap to Paginate +.right { + position: absolute; + right: 0px; + top: $action-bar-height; + width: 20vw; + z-index: 3; + background: $pagination-color; + border-color: transparent; + border: none !important; + opacity: $pagination-opacity; + outline: none; + height: 100%; + + &.immersive { + top: 0px; + } + + &.no-pointer-events { + pointer-events: none; + } +} + + + +// This class pushes the click area to the left a bit to let users click the scrollbar +.right-with-scrollbar { + position: absolute; + right: 17px; + top: $action-bar-height; + width: 18vw; + z-index: 3; + background: $pagination-color; + opacity: $pagination-opacity; + border-color: transparent; + border: none !important; + outline: none; + height: 100%; + cursor: pointer; + + &.immersive { + top: 0px; + } +} + +.left { + position: absolute; + left: 0px; + top: $action-bar-height; + width: 20vw; + background: $pagination-color; + opacity: $pagination-opacity; + border-color: transparent; + border: none !important; + z-index: 3; + outline: none; + height: 100%; + cursor: pointer; + + &.immersive { + top: 0px; + } +} + + 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 7bf945d5d..001df9c74 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 @@ -1,27 +1,43 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, ElementRef, - HostListener, - inject, OnDestroy, - OnInit, ViewChild + Component, + ElementRef, + HostListener, inject, Inject, + OnDestroy, + OnInit, + ViewChild } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { NgxExtendedPdfViewerService, PageViewModeType, ScrollModeType, ProgressBarEvent, NgxExtendedPdfViewerModule } from 'ngx-extended-pdf-viewer'; -import { ToastrService } from 'ngx-toastr'; -import { take } from 'rxjs'; -import { BookService } from 'src/app/book-reader/_services/book.service'; -import { KEY_CODES } from 'src/app/shared/_services/utility.service'; -import { Chapter } from 'src/app/_models/chapter'; -import { User } from 'src/app/_models/user'; -import { AccountService } from 'src/app/_services/account.service'; -import { NavService } from 'src/app/_services/nav.service'; -import { CHAPTER_ID_DOESNT_EXIST, ReaderService } from 'src/app/_services/reader.service'; -import { SeriesService } from 'src/app/_services/series.service'; -import { ThemeService } from 'src/app/_services/theme.service'; -import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import { NgIf, NgStyle, AsyncPipe } from '@angular/common'; +import {ActivatedRoute, Router} from '@angular/router'; +import { + NgxExtendedPdfViewerModule, + NgxExtendedPdfViewerService, + PageViewModeType, + ProgressBarEvent, + ScrollModeType +} from 'ngx-extended-pdf-viewer'; +import {ToastrService} from 'ngx-toastr'; +import {take} from 'rxjs'; +import {BookService} from 'src/app/book-reader/_services/book.service'; +import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; +import {Chapter} from 'src/app/_models/chapter'; +import {User} from 'src/app/_models/user'; +import {AccountService} from 'src/app/_services/account.service'; +import {NavService} from 'src/app/_services/nav.service'; +import {CHAPTER_ID_DOESNT_EXIST, ReaderService} from 'src/app/_services/reader.service'; +import {SeriesService} from 'src/app/_services/series.service'; +import {ThemeService} from 'src/app/_services/theme.service'; +import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; +import {AsyncPipe, DOCUMENT, NgIf, NgStyle} from '@angular/common'; import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {PdfLayoutMode} from "../../../_models/preferences/pdf-layout-mode"; +import {PdfScrollMode} from "../../../_models/preferences/pdf-scroll-mode"; +import {PdfTheme} from "../../../_models/preferences/pdf-theme"; +import {PdfSpreadMode} from "../../../_models/preferences/pdf-spread-mode"; +import {SpreadType} from "ngx-extended-pdf-viewer/lib/options/spread-type"; +import {PdfLayoutModePipe} from "../../_pipe/pdf-layout-mode.pipe"; +import {PdfScrollModePipe} from "../../_pipe/pdf-scroll-mode.pipe"; +import {PdfSpreadModePipe} from "../../_pipe/pdf-spread-mode.pipe"; @Component({ selector: 'app-pdf-reader', @@ -29,10 +45,26 @@ import {translate, TranslocoDirective} from "@ngneat/transloco"; styleUrls: ['./pdf-reader.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, NgStyle, NgxExtendedPdfViewerModule, NgbTooltip, AsyncPipe, TranslocoDirective] + imports: [NgIf, NgStyle, NgxExtendedPdfViewerModule, NgbTooltip, AsyncPipe, TranslocoDirective, + PdfLayoutModePipe, PdfScrollModePipe, PdfSpreadModePipe] }) export class PdfReaderComponent implements OnInit, OnDestroy { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly seriesService = inject(SeriesService); + private readonly navService = inject(NavService); + private readonly toastr = inject(ToastrService); + private readonly bookService = inject(BookService); + private readonly themeService = inject(ThemeService); + private readonly cdRef = inject(ChangeDetectorRef); + public readonly accountService = inject(AccountService); + public readonly readerService = inject(ReaderService); + public readonly utilityService = inject(UtilityService); + + protected readonly ScrollModeType = ScrollModeType; + protected readonly Breakpoint = Breakpoint; + @ViewChild('container') container!: ElementRef; libraryId!: number; @@ -82,20 +114,13 @@ export class PdfReaderComponent implements OnInit, OnDestroy { * How much of the current document is loaded */ loadPercent: number = 0; + scrollbarNeeded = false; - /** - * This can't be updated dynamically: - * https://github.com/stephanrauh/ngx-extended-pdf-viewer/issues/1415 - */ - bookMode: PageViewModeType = 'multiple'; - + pageLayoutMode: PageViewModeType = 'multiple'; scrollMode: ScrollModeType = ScrollModeType.vertical; + spreadMode: SpreadType = 'off'; - constructor(private route: ActivatedRoute, private router: Router, public accountService: AccountService, - private seriesService: SeriesService, public readerService: ReaderService, - private navService: NavService, private toastr: ToastrService, - private bookService: BookService, private themeService: ThemeService, - private readonly cdRef: ChangeDetectorRef, private pdfViewerService: NgxExtendedPdfViewerService) { + constructor(@Inject(DOCUMENT) private document: Document) { this.navService.hideNavBar(); this.themeService.clearThemes(); this.navService.hideSideNav(); @@ -108,6 +133,13 @@ export class PdfReaderComponent implements OnInit, OnDestroy { } } + @HostListener('window:resize', ['$event']) + @HostListener('window:orientationchange', ['$event']) + onResize(){ + // Update the window Height + this.calcScrollbarNeeded(); + } + ngOnDestroy(): void { this.themeService.currentTheme$.pipe(take(1)).subscribe(theme => { this.themeService.setTheme(theme.name); @@ -150,7 +182,71 @@ export class PdfReaderComponent implements OnInit, OnDestroy { }); } + calcScrollbarNeeded() { + const viewContainer = this.document.querySelector('#viewerContainer'); + if (viewContainer == null) return; + this.scrollbarNeeded = viewContainer.scrollHeight > this.container?.nativeElement?.clientHeight; + this.cdRef.markForCheck(); + } + + convertPdfLayoutMode(mode: PdfLayoutMode) { + switch (mode) { + case PdfLayoutMode.Multiple: + return 'multiple'; + case PdfLayoutMode.Single: + return 'single'; + case PdfLayoutMode.Book: + return 'book'; + case PdfLayoutMode.InfiniteScroll: + return 'infinite-scroll'; + + } + } + + convertPdfScrollMode(mode: PdfScrollMode) { + switch (mode) { + case PdfScrollMode.Vertical: + return ScrollModeType.vertical; + case PdfScrollMode.Horizontal: + return ScrollModeType.horizontal; + case PdfScrollMode.Wrapped: + return ScrollModeType.wrapped; + case PdfScrollMode.Page: + return ScrollModeType.page; + } + } + + convertPdfSpreadMode(mode: PdfSpreadMode): SpreadType { + switch (mode) { + case PdfSpreadMode.None: + return 'off' as SpreadType; + case PdfSpreadMode.Odd: + return 'odd' as SpreadType; + case PdfSpreadMode.Even: + return 'even' as SpreadType; + } + } + + convertPdfTheme(theme: PdfTheme) { + switch (theme) { + case PdfTheme.Dark: + return 'dark'; + case PdfTheme.Light: + return 'light'; + } + } + init() { + + this.pageLayoutMode = this.convertPdfLayoutMode(this.user.preferences.pdfLayoutMode || PdfLayoutMode.Multiple); + this.scrollMode = this.convertPdfScrollMode(this.user.preferences.pdfScrollMode || PdfScrollMode.Vertical); + this.spreadMode = this.convertPdfSpreadMode(this.user.preferences.pdfSpreadMode || PdfSpreadMode.None); + this.theme = this.convertPdfTheme(this.user.preferences.pdfTheme || PdfTheme.Dark); + this.backgroundColor = this.themeMap[this.theme].background; + this.fontColor = this.themeMap[this.theme].font; // TODO: Move this to an observable or something + + this.calcScrollbarNeeded(); + this.bookService.getBookInfo(this.chapterId).subscribe(info => { this.volumeId = info.volumeId; this.bookTitle = info.bookTitle; @@ -171,7 +267,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy { } this.cdRef.markForCheck(); }); - this.readerService.enableWakeLock(this.container.nativeElement); + setTimeout(() => this.readerService.enableWakeLock(this.container.nativeElement), 1000); // TODO: This needs to be in afterviewinit i think } /** @@ -197,11 +293,33 @@ export class PdfReaderComponent implements OnInit, OnDestroy { this.cdRef.markForCheck(); } + toggleScrollMode() { + const options: Array = [ScrollModeType.vertical, ScrollModeType.horizontal, ScrollModeType.page]; + let index = options.indexOf(this.scrollMode) + 1; + if (index >= options.length) index = 0; + this.scrollMode = options[index]; + + this.calcScrollbarNeeded(); + + this.cdRef.markForCheck(); + } + + toggleSpreadMode() { + const options: Array = ['off', 'odd', 'even']; + let index = options.indexOf(this.spreadMode) + 1; + if (index >= options.length) index = 0; + this.spreadMode = options[index]; + + + this.cdRef.markForCheck(); + } + toggleBookPageMode() { - if (this.bookMode === 'book') { - this.bookMode = 'multiple'; + if (this.pageLayoutMode === 'book') { + this.pageLayoutMode = 'multiple'; } else { - this.bookMode = 'book'; + this.pageLayoutMode = 'book'; + // If the fit is automatic, let's adjust to 100% to ensure it renders correctly (can't do this, but it doesn't always happen) } this.cdRef.markForCheck(); } @@ -225,4 +343,16 @@ export class PdfReaderComponent implements OnInit, OnDestroy { this.cdRef.markForCheck(); } + prevPage() { + this.currentPage--; + if (this.currentPage < 0) this.currentPage = 0; + this.cdRef.markForCheck(); + } + + nextPage() { + this.currentPage++; + if (this.currentPage > this.maxPages) this.currentPage = this.maxPages; + this.cdRef.markForCheck(); + } + } diff --git a/UI/Web/src/app/pdf-reader/_pipe/pdf-layout-mode.pipe.ts b/UI/Web/src/app/pdf-reader/_pipe/pdf-layout-mode.pipe.ts new file mode 100644 index 000000000..44bb5d70d --- /dev/null +++ b/UI/Web/src/app/pdf-reader/_pipe/pdf-layout-mode.pipe.ts @@ -0,0 +1,26 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {PageViewModeType} from "ngx-extended-pdf-viewer"; +import {TranslocoService} from "@ngneat/transloco"; + +@Pipe({ + name: 'pdfLayoutMode', + standalone: true +}) +export class PdfLayoutModePipe implements PipeTransform { + + translocoService = inject(TranslocoService); + transform(value: PageViewModeType): string { + switch (value) { + case "single": + return this.translocoService.translate('pdf-layout-mode-pipe.single'); + case "book": + return this.translocoService.translate('pdf-layout-mode-pipe.book'); + case "multiple": + return this.translocoService.translate('pdf-layout-mode-pipe.multiple'); + case "infinite-scroll": + return this.translocoService.translate('pdf-layout-mode-pipe.infinite-scroll'); + + } + } + +} diff --git a/UI/Web/src/app/pdf-reader/_pipe/pdf-scroll-mode.pipe.ts b/UI/Web/src/app/pdf-reader/_pipe/pdf-scroll-mode.pipe.ts new file mode 100644 index 000000000..1e6d85211 --- /dev/null +++ b/UI/Web/src/app/pdf-reader/_pipe/pdf-scroll-mode.pipe.ts @@ -0,0 +1,24 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {TranslocoService} from "@ngneat/transloco"; +import {ScrollModeType} from "ngx-extended-pdf-viewer"; + +@Pipe({ + name: 'pdfScrollMode', + standalone: true +}) +export class PdfScrollModePipe implements PipeTransform { + translocoService = inject(TranslocoService); + transform(value: ScrollModeType): string { + switch (value) { + case ScrollModeType.vertical: + return this.translocoService.translate('pdf-scroll-mode-pipe.vertical'); + case ScrollModeType.horizontal: + return this.translocoService.translate('pdf-scroll-mode-pipe.horizontal'); + case ScrollModeType.wrapped: + return this.translocoService.translate('pdf-scroll-mode-pipe.wrapped'); + case ScrollModeType.page: + return this.translocoService.translate('pdf-scroll-mode-pipe.page'); + } + } + +} diff --git a/UI/Web/src/app/pdf-reader/_pipe/pdf-spread-mode.pipe.ts b/UI/Web/src/app/pdf-reader/_pipe/pdf-spread-mode.pipe.ts new file mode 100644 index 000000000..b4f53c83b --- /dev/null +++ b/UI/Web/src/app/pdf-reader/_pipe/pdf-spread-mode.pipe.ts @@ -0,0 +1,24 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {TranslocoService} from "@ngneat/transloco"; +import {SpreadType} from "ngx-extended-pdf-viewer/lib/options/spread-type"; + +@Pipe({ + name: 'pdfSpreadMode', + standalone: true +}) +export class PdfSpreadModePipe implements PipeTransform { + translocoService = inject(TranslocoService); + + transform(value: SpreadType): string { + switch (value) { + case 'off' as SpreadType: + return this.translocoService.translate('pdf-spread-mode-pipe.off'); + case "even": + return this.translocoService.translate('pdf-spread-mode-pipe.even'); + case "odd": + return this.translocoService.translate('pdf-spread-mode-pipe.odd'); + } + return this.translocoService.translate('pdf-spread-mode-pipe.off'); + } + +} diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts index e2a1d179c..c145ff135 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts @@ -16,6 +16,7 @@ import { SiteThemeProviderPipe } from '../../_pipes/site-theme-provider.pipe'; import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe'; import { NgIf, NgFor, AsyncPipe } from '@angular/common'; import {TranslocoDirective, TranslocoService} from "@ngneat/transloco"; +import {tap} from "rxjs/operators"; @Component({ selector: 'app-theme-manager', @@ -52,19 +53,11 @@ export class ThemeManagerComponent { } applyTheme(theme: SiteTheme) { + if (!this.user) return; - if (this.user) { - const pref = Object.assign({}, this.user.preferences); - pref.theme = theme; - this.accountService.updatePreferences(pref).subscribe(updatedPref => { - if (this.user) { - this.user.preferences = updatedPref; - } - this.themeService.setTheme(theme.name); - this.cdRef.markForCheck(); - }); - } - + const pref = Object.assign({}, this.user.preferences); + pref.theme = theme; + this.accountService.updatePreferences(pref).subscribe(); } updateDefault(theme: SiteTheme) { diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index 6b55ba054..b093676c6 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -1,152 +1,36 @@ -

- {{t('title')}} -

+

+ {{t('title')}} +

-
+ +
+

+ +

+
+
+ +
+
+ + {{t('reading-direction-tooltip')}} + + + + +
+ +
+ + {{t('scaling-option-tooltip')}} + + + + +
+
+ +
+
+ {{t('page-splitting-tooltip')}} - +
- - +
- + {{t('layout-mode-tooltip')}} - +
- - + +
@@ -222,7 +280,8 @@
- +
@@ -230,8 +289,11 @@
- - + +
@@ -241,24 +303,36 @@
- - + +
- - + +
- - + +
@@ -267,20 +341,26 @@

- -

-
-
- -
-
- -
-
- - + + +
+
+ +
+
+ +
+
+ + {{t('tap-to-paginate-tooltip')}} @@ -292,8 +372,13 @@
- - + + {{t('immersive-mode-label')}} @@ -305,24 +390,35 @@
- - {{t('reading-direction-book-tooltip')}} + + {{t('reading-direction-book-tooltip')}} + - +
- + {{t('font-family-tooltip')}} -
@@ -330,24 +426,35 @@
- + {{t('writing-style-tooltip')}} - +
- + {{t('layout-mode-book-tooltip')}} - +
@@ -355,13 +462,18 @@
- + {{t('color-theme-book-tooltip')}} - +
@@ -369,79 +481,181 @@
- + {{settingsForm.get('bookReaderFontSize')?.value + '%'}}
- + {{t('line-height-book-tooltip')}}
+ formControlName="bookReaderLineSpacing" + aria-describedby="settings-booklineheight-option-help"> {{settingsForm.get('bookReaderLineSpacing')?.value + '%'}}
- + {{t('margin-book-tooltip')}}
- + {{settingsForm.get('bookReaderMargin')?.value + '%'}}
- - + + +
+ +
+
+
+ +
+

+ +

+
+
+ + +
+
+ + {{t('pdf-layout-mode-tooltip')}} + + + + + +
+ + +
+ + {{t('pdf-scroll-mode-tooltip')}} + + + + + +
+
+ +
+
+ + {{t('pdf-spread-mode-tooltip')}} + + + + + +
+ + +
+ + +
+
+ + +
+ +
- - - } + + + } - @defer (when tab.fragment === FragmentID.Clients; prefetch on idle) { - -

{{t('clients-opds-description')}}

- - - } - @defer (when tab.fragment === FragmentID.Theme; prefetch on idle) { - - } + @defer (when tab.fragment === FragmentID.Clients; prefetch on idle) { + +

{{t('clients-opds-description')}}

+ + + } + @defer (when tab.fragment === FragmentID.Theme; prefetch on idle) { + + } - @defer (when tab.fragment === FragmentID.Devices; prefetch on idle) { - - } + @defer (when tab.fragment === FragmentID.Devices; prefetch on idle) { + + } - @defer (when tab.fragment === FragmentID.Stats; prefetch on idle) { - - } + @defer (when tab.fragment === FragmentID.Stats; prefetch on idle) { + + } - @defer (when tab.fragment === FragmentID.Scrobbling; prefetch on idle) { - @if(hasActiveLicense) { - - - } - } - - - -
+ @defer (when tab.fragment === FragmentID.Scrobbling; prefetch on idle) { + @if(hasActiveLicense) { + + + } + } + + + +
diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index b37c1ffe6..d05d2646c 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -7,54 +7,82 @@ import { OnDestroy, OnInit } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { ToastrService } from 'ngx-toastr'; +import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {ToastrService} from 'ngx-toastr'; import {take} from 'rxjs/operators'; -import { Title } from '@angular/platform-browser'; +import {Title} from '@angular/platform-browser'; import { - readingDirections, - scalingOptions, - pageSplitOptions, - readingModes, - Preferences, bookLayoutModes, + bookWritingStyles, layoutModes, pageLayoutModes, - bookWritingStyles + pageSplitOptions, + pdfLayoutModes, + pdfScrollModes, + pdfSpreadModes, + pdfThemes, + Preferences, + readingDirections, + readingModes, + scalingOptions } from 'src/app/_models/preferences/preferences'; -import { User } from 'src/app/_models/user'; -import { AccountService } from 'src/app/_services/account.service'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { SettingsService } from 'src/app/admin/settings.service'; -import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mode'; +import {User} from 'src/app/_models/user'; +import {AccountService} from 'src/app/_services/account.service'; +import {ActivatedRoute, Router, RouterLink} from '@angular/router'; +import {SettingsService} from 'src/app/admin/settings.service'; +import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode'; import {forkJoin} from 'rxjs'; -import { bookColorThemes } from 'src/app/book-reader/_components/reader-settings/reader-settings.component'; -import { BookService } from 'src/app/book-reader/_services/book.service'; +import {bookColorThemes} from 'src/app/book-reader/_components/reader-settings/reader-settings.component'; +import {BookService} from 'src/app/book-reader/_services/book.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe'; -import { UserHoldsComponent } from '../user-holds/user-holds.component'; -import { UserScrobbleHistoryComponent } from '../../_single-module/user-scrobble-history/user-scrobble-history.component'; -import { UserStatsComponent } from '../../statistics/_components/user-stats/user-stats.component'; -import { ManageDevicesComponent } from '../manage-devices/manage-devices.component'; -import { ThemeManagerComponent } from '../theme-manager/theme-manager.component'; -import { ApiKeyComponent } from '../api-key/api-key.component'; -import { ColorPickerModule } from 'ngx-color-picker'; -import { ChangeAgeRestrictionComponent } from '../change-age-restriction/change-age-restriction.component'; -import { ChangePasswordComponent } from '../change-password/change-password.component'; -import { ChangeEmailComponent } from '../change-email/change-email.component'; -import { NgFor, NgIf, NgTemplateOutlet, TitleCasePipe } from '@angular/common'; -import { NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'; -import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; +import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe'; +import {UserHoldsComponent} from '../user-holds/user-holds.component'; +import {UserScrobbleHistoryComponent} from '../../_single-module/user-scrobble-history/user-scrobble-history.component'; +import {UserStatsComponent} from '../../statistics/_components/user-stats/user-stats.component'; +import {ManageDevicesComponent} from '../manage-devices/manage-devices.component'; +import {ThemeManagerComponent} from '../theme-manager/theme-manager.component'; +import {ApiKeyComponent} from '../api-key/api-key.component'; +import {ColorPickerModule} from 'ngx-color-picker'; +import {ChangeAgeRestrictionComponent} from '../change-age-restriction/change-age-restriction.component'; +import {ChangePasswordComponent} from '../change-password/change-password.component'; +import {ChangeEmailComponent} from '../change-email/change-email.component'; +import {NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; +import { + NgbAccordionBody, + NgbAccordionButton, + NgbAccordionCollapse, + NgbAccordionDirective, + NgbAccordionHeader, + NgbAccordionItem, + NgbAccordionToggle, + NgbCollapse, + NgbNav, + NgbNavContent, + NgbNavItem, + NgbNavItemRole, + NgbNavLink, + NgbNavOutlet, + NgbTooltip +} from '@ng-bootstrap/ng-bootstrap'; +import { + SideNavCompanionBarComponent +} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {LocalizationService} from "../../_services/localization.service"; import {Language} from "../../_models/metadata/language"; import {translate, TranslocoDirective} from "@ngneat/transloco"; import {LoadingComponent} from "../../shared/loading/loading.component"; import {ManageScrobblingProvidersComponent} from "../manage-scrobbling-providers/manage-scrobbling-providers.component"; +import {PdfLayoutModePipe} from "../../pdf-reader/_pipe/pdf-layout-mode.pipe"; +import {PdfTheme} from "../../_models/preferences/pdf-theme"; +import {PdfScrollMode} from "../../_models/preferences/pdf-scroll-mode"; +import {PdfLayoutMode} from "../../_models/preferences/pdf-layout-mode"; +import {PdfSpreadMode} from "../../_models/preferences/pdf-spread-mode"; enum AccordionPanelID { ImageReader = 'image-reader', BookReader = 'book-reader', - GlobalSettings = 'global-settings' + GlobalSettings = 'global-settings', + PdfReader = 'pdf-reader' } enum FragmentID { @@ -77,7 +105,7 @@ enum FragmentID { ChangePasswordComponent, ChangeAgeRestrictionComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent, ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe, - TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent], + TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent, PdfLayoutModePipe], }) export class UserPreferencesComponent implements OnInit, OnDestroy { @@ -108,6 +136,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { pageLayoutModesTranslated = pageLayoutModes.map(this.translatePrefOptions); bookWritingStylesTranslated = bookWritingStyles.map(this.translatePrefOptions); + pdfLayoutModesTranslated = pdfLayoutModes.map(this.translatePrefOptions); + pdfScrollModesTranslated = pdfScrollModes.map(this.translatePrefOptions); + pdfSpreadModesTranslated = pdfSpreadModes.map(this.translatePrefOptions); + pdfThemesTranslated = pdfThemes.map(this.translatePrefOptions); + settingsForm: FormGroup = new FormGroup({}); user: User | undefined = undefined; @@ -129,7 +162,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { opdsUrl: string = ''; makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; }; hasActiveLicense = false; - canEdit = true; @@ -214,11 +246,16 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, [])); this.settingsForm.addControl('bookReaderReadingDirection', new FormControl(this.user.preferences.bookReaderReadingDirection, [])); this.settingsForm.addControl('bookReaderWritingStyle', new FormControl(this.user.preferences.bookReaderWritingStyle, [])) - this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(!!this.user.preferences.bookReaderTapToPaginate, [])); + this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.user.preferences.bookReaderTapToPaginate, [])); this.settingsForm.addControl('bookReaderLayoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, [])); this.settingsForm.addControl('bookReaderThemeName', new FormControl(this.user?.preferences.bookReaderThemeName || bookColorThemes[0].name, [])); this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.user?.preferences.bookReaderImmersiveMode, [])); + this.settingsForm.addControl('pdfTheme', new FormControl(this.user?.preferences.pdfTheme || PdfTheme.Dark, [])); + this.settingsForm.addControl('pdfScrollMode', new FormControl(this.user?.preferences.pdfScrollMode || PdfScrollMode.Vertical, [])); + this.settingsForm.addControl('pdfLayoutMode', new FormControl(this.user?.preferences.pdfLayoutMode || PdfLayoutMode.Multiple, [])); + this.settingsForm.addControl('pdfSpreadMode', new FormControl(this.user?.preferences.pdfSpreadMode || PdfSpreadMode.None, [])); + this.settingsForm.addControl('theme', new FormControl(this.user.preferences.theme, [])); this.settingsForm.addControl('globalPageLayoutMode', new FormControl(this.user.preferences.globalPageLayoutMode, [])); this.settingsForm.addControl('blurUnreadSummaries', new FormControl(this.user.preferences.blurUnreadSummaries, [])); @@ -278,6 +315,12 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.settingsForm.get('collapseSeriesRelationships')?.setValue(this.user.preferences.collapseSeriesRelationships); this.settingsForm.get('shareReviews')?.setValue(this.user.preferences.shareReviews); this.settingsForm.get('locale')?.setValue(this.user.preferences.locale); + + this.settingsForm.get('pdfTheme')?.setValue(this.user.preferences.pdfTheme); + this.settingsForm.get('pdfScrollMode')?.setValue(this.user.preferences.pdfScrollMode); + this.settingsForm.get('pdfLayoutMode')?.setValue(this.user.preferences.pdfLayoutMode); + this.settingsForm.get('pdfSpreadMode')?.setValue(this.user.preferences.pdfSpreadMode); + this.cdRef.markForCheck(); this.settingsForm.markAsPristine(); } @@ -313,7 +356,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { swipeToPaginate: modelSettings.swipeToPaginate, collapseSeriesRelationships: modelSettings.collapseSeriesRelationships, shareReviews: modelSettings.shareReviews, - locale: modelSettings.locale + locale: modelSettings.locale, + pdfTheme: parseInt(modelSettings.pdfTheme, 10), + pdfScrollMode: parseInt(modelSettings.pdfScrollMode, 10), + pdfLayoutMode: parseInt(modelSettings.pdfLayoutMode, 10), + pdfSpreadMode: parseInt(modelSettings.pdfSpreadMode, 10), }; this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 9142ac801..9a7839d4e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -159,6 +159,15 @@ "margin-book-label": "Margin", "margin-book-tooltip": "How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.", + "pdf-reader-settings-title": "PDF Reader", + "pdf-layout-mode-label": "Layout Mode", + "pdf-layout-mode-tooltip": "How the reader lays the pdf out. Default is pages stacked with scrolling and Book emulates a physical book", + "pdf-scroll-mode-label": "Scroll Mode", + "pdf-scroll-mode-tooltip": "How you scroll through pages. Vertical/Horizontal and Tap to Paginate (no scroll)", + "pdf-spread-mode-label": "Spread Mode", + "pdf-spread-mode-tooltip": "How pages should be laid out. Single or double (odd/even)", + "pdf-theme-label": "Theme", + "clients-opds-alert": "OPDS is not enabled on this server. This will not affect Tachiyomi users.", "clients-opds-description": "All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.", "clients-api-key-tooltip": "The API key is like a password. Keep it secret, Keep it safe.", @@ -1600,7 +1609,28 @@ "incognito-mode": "Incognito Mode", "light-theme-alt": "Light Theme", "dark-theme-alt": "Dark Theme", - "close-reader-alt": "Close Reader" + "close-reader-alt": "Close Reader", + "toggle-incognito": "Turn off Incognito Mode" + }, + + "pdf-layout-mode-pipe": { + "single": "Single Page", + "book": "Book mode", + "multiple": "Default", + "infinite-scroll": "Infinite Scroll" + }, + + "pdf-scroll-mode-pipe": { + "vertical": "Vertical", + "horizontal": "Horizontal", + "wrapped": "Wrapped", + "page": "Tap to Paginate" + }, + + "pdf-spread-mode-pipe": { + "off": "No Spreads", + "odd": "Odd Spreads", + "even": "Even Spreads" }, "infinite-scroller": { @@ -2189,7 +2219,17 @@ "2-column": "2 Column", "cards": "Cards", "list": "List", - "up-to-down": "Up to Down" + "up-to-down": "Up to Down", + "pdf-multiple": "Default", + "pdf-book": "Book", + "pdf-vertical": "Scroll Vertical", + "pdf-horizontal": "Scroll Horizontal", + "pdf-page": "Tap to Paginate", + "pdf-none": "None", + "pdf-odd": "Odd", + "pdf-even": "Even", + "pdf-light": "Light", + "pdf-dark": "Dark" }, diff --git a/UI/Web/src/environments/environment.ts b/UI/Web/src/environments/environment.ts index 11db3c23e..b86267283 100644 --- a/UI/Web/src/environments/environment.ts +++ b/UI/Web/src/environments/environment.ts @@ -2,10 +2,12 @@ // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. // The list of file replacements can be found in `angular.json`. +const IP = 'localhost'; + export const environment = { production: false, - apiUrl: 'http://localhost:5000/api/', - hubUrl: 'http://localhost:5000/hubs/', + apiUrl: 'http://' + IP + ':5000/api/', + hubUrl: 'http://'+ IP + ':5000/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/httpLoader.ts b/UI/Web/src/httpLoader.ts index 85cb2ec95..7c551c127 100644 --- a/UI/Web/src/httpLoader.ts +++ b/UI/Web/src/httpLoader.ts @@ -10,6 +10,8 @@ export class HttpLoader implements TranslocoLoader { getTranslation(langPath: string) { const tokens = langPath.split('/'); const langCode = tokens[tokens.length - 1]; - return this.http.get(`assets/langs/${langCode}.json?v=${(cacheBusting as { [key: string]: string })[langCode]}`); + const url = `assets/langs/${langCode}.json?v=${(cacheBusting as { [key: string]: string })[langCode]}`; + console.log('loading locale: ', url); + return this.http.get(url); } } diff --git a/UI/Web/src/main.ts b/UI/Web/src/main.ts index fbc53c6cd..26eeaabde 100644 --- a/UI/Web/src/main.ts +++ b/UI/Web/src/main.ts @@ -28,12 +28,13 @@ import {provideTranslocoLocale} from "@ngneat/transloco-locale"; import {provideTranslocoPersistTranslations} from "@ngneat/transloco-persist-translations"; import {LazyLoadImageModule} from "ng-lazyload-image"; import {getSaver, SAVER} from "./app/_providers/saver.provider"; +import {distinctUntilChanged} from "rxjs/operators"; const disableAnimations = !('animate' in document.documentElement); export function preloadUser(userService: AccountService, transloco: TranslocoService) { return function() { - return userService.currentUser$.pipe(switchMap((user) => { + return userService.currentUser$.pipe(distinctUntilChanged(), switchMap((user) => { if (user && user.preferences.locale) { transloco.setActiveLang(user.preferences.locale); return transloco.load(user.preferences.locale) diff --git a/openapi.json b/openapi.json index 9884a5f1e..79c87bec1 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.14.9" + "version": "0.7.14.10" }, "servers": [ { @@ -13300,9 +13300,6 @@ "description": "Book Reader Option: Defines the writing styles vertical/horizontal", "format": "int32" }, - "theme": { - "$ref": "#/components/schemas/SiteTheme" - }, "bookThemeName": { "type": "string", "description": "Book Reader Option: The color theme to decorate the book contents", @@ -13322,6 +13319,47 @@ "type": "boolean", "description": "Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this." }, + "pdfTheme": { + "enum": [ + 0, + 1 + ], + "type": "integer", + "description": "PDF Reader: Theme of the Reader", + "format": "int32" + }, + "pdfScrollMode": { + "enum": [ + 0, + 1, + 3 + ], + "type": "integer", + "description": "PDF Reader: Scroll mode of the reader", + "format": "int32" + }, + "pdfLayoutMode": { + "enum": [ + 0, + 2 + ], + "type": "integer", + "description": "PDF Reader: Layout Mode of the reader", + "format": "int32" + }, + "pdfSpreadMode": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "description": "PDF Reader: Spread Mode of the reader", + "format": "int32" + }, + "theme": { + "$ref": "#/components/schemas/SiteTheme" + }, "globalPageLayoutMode": { "enum": [ 0, @@ -20626,6 +20664,10 @@ "locale", "noTransitions", "pageSplitOption", + "pdfLayoutMode", + "pdfScrollMode", + "pdfSpreadMode", + "pdfTheme", "promptForDownloadSize", "readerMode", "readingDirection", @@ -20804,6 +20846,44 @@ "minLength": 1, "type": "string", "description": "UI Site Global Setting: The language locale that should be used for the user" + }, + "pdfTheme": { + "enum": [ + 0, + 1 + ], + "type": "integer", + "description": "PDF Reader: Theme of the Reader", + "format": "int32" + }, + "pdfScrollMode": { + "enum": [ + 0, + 1, + 3 + ], + "type": "integer", + "description": "PDF Reader: Scroll mode of the reader", + "format": "int32" + }, + "pdfLayoutMode": { + "enum": [ + 0, + 2 + ], + "type": "integer", + "description": "PDF Reader: Layout Mode of the reader", + "format": "int32" + }, + "pdfSpreadMode": { + "enum": [ + 0, + 1, + 2 + ], + "type": "integer", + "description": "PDF Reader: Spread Mode of the reader", + "format": "int32" } }, "additionalProperties": false