diff --git a/.gitignore b/.gitignore index c8d68977f..eec036cbe 100644 --- a/.gitignore +++ b/.gitignore @@ -517,10 +517,12 @@ UI/Web/dist/ /API/config/kavita.db-shm /API/config/kavita.db-wal /API/config/kavita.db-journal +/API/config/*.db +/API/config/*.bak +/API/config/*.backup /API/config/Hangfire.db /API/config/Hangfire-log.db API/config/covers/ -API/config/*.db API/config/stats/* API/config/stats/app_stats.json API/config/pre-metadata/ diff --git a/API.Benchmark/ParseScannedFilesBenchmarks.cs b/API.Benchmark/ParseScannedFilesBenchmarks.cs index 7c244a5d4..1dcca79b9 100644 --- a/API.Benchmark/ParseScannedFilesBenchmarks.cs +++ b/API.Benchmark/ParseScannedFilesBenchmarks.cs @@ -1,5 +1,6 @@ using System.IO; using System.IO.Abstractions; +using System.Threading.Tasks; using API.Entities.Enums; using API.Parser; using API.Services; @@ -46,7 +47,7 @@ namespace API.Benchmark /// Generate a list of Series and another list with /// [Benchmark] - public void MergeName() + public async Task MergeName() { var libraryPath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Manga"); @@ -61,7 +62,7 @@ namespace API.Benchmark Title = "A Town Where You Live", Volumes = "1" }; - _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, "Manga"); + await _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, "Manga"); _parseScannedFiles.MergeName(p1); } } diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 3b4b8cb94..708e253c0 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -7,10 +7,10 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs index 72d6908c6..df3934884 100644 --- a/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs +++ b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs @@ -14,4 +14,11 @@ public class ChapterSortComparerZeroFirstTests { Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray()); } + + [Theory] + [InlineData(new[] {1.0, 0.5, 0.3}, new[] {0.3, 0.5, 1.0})] + public void ChapterSortComparerZeroFirstTest_Doubles(double[] input, double[] expected) + { + Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray()); + } } diff --git a/API.Tests/Comparers/NumericComparerTests.cs b/API.Tests/Comparers/NumericComparerTests.cs index 9a66e7666..8a1f23773 100644 --- a/API.Tests/Comparers/NumericComparerTests.cs +++ b/API.Tests/Comparers/NumericComparerTests.cs @@ -11,6 +11,10 @@ public class NumericComparerTests new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} )] + [InlineData( + new[] {"x1.0.jpg", "0.5.jpg", "0.3.jpg"}, + new[] {"0.3.jpg", "0.5.jpg", "x1.0.jpg",} + )] public void NumericComparerTest(string[] input, string[] expected) { var nc = new NumericComparer(); diff --git a/API.Tests/Entities/ComicInfoTests.cs b/API.Tests/Entities/ComicInfoTests.cs index 7b7106eb9..325299cf8 100644 --- a/API.Tests/Entities/ComicInfoTests.cs +++ b/API.Tests/Entities/ComicInfoTests.cs @@ -16,7 +16,7 @@ public class ComicInfoTests [InlineData("Early Childhood", AgeRating.EarlyChildhood)] [InlineData("Everyone 10+", AgeRating.Everyone10Plus)] [InlineData("M", AgeRating.Mature)] - [InlineData("MA 15+", AgeRating.Mature15Plus)] + [InlineData("MA15+", AgeRating.Mature15Plus)] [InlineData("Mature 17+", AgeRating.Mature17Plus)] [InlineData("Rating Pending", AgeRating.RatingPending)] [InlineData("X18+", AgeRating.X18Plus)] diff --git a/API.Tests/Extensions/ChapterListExtensionsTests.cs b/API.Tests/Extensions/ChapterListExtensionsTests.cs index 845b1387b..a1beddf09 100644 --- a/API.Tests/Extensions/ChapterListExtensionsTests.cs +++ b/API.Tests/Extensions/ChapterListExtensionsTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using API.Entities; using API.Entities.Enums; @@ -83,9 +83,37 @@ namespace API.Tests.Extensions Assert.Equal(chapterList[0], actualChapter); } + [Fact] + public void GetChapterByRange_On_Duplicate_Files_Test_Should_Not_Error() + { + var info = new ParserInfo() + { + Chapters = "0", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/detective comics #001.cbz", + Filename = "detective comics #001.cbz", + IsSpecial = true, + Series = "detective comics", + Title = "detective comics", + Volumes = "0" + }; + + var chapterList = new List() + { + CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true), + CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) + }; + + var actualChapter = chapterList.GetChapterByRange(info); + + Assert.Equal(chapterList[0], actualChapter); + + } + #region GetFirstChapterWithFiles - [Fact] + [Fact] public void GetFirstChapterWithFiles_ShouldReturnAllChapters() { var chapterList = new List() diff --git a/API.Tests/Extensions/VolumeListExtensionsTests.cs b/API.Tests/Extensions/VolumeListExtensionsTests.cs index 48d39aa24..264437ecd 100644 --- a/API.Tests/Extensions/VolumeListExtensionsTests.cs +++ b/API.Tests/Extensions/VolumeListExtensionsTests.cs @@ -9,67 +9,6 @@ namespace API.Tests.Extensions; public class VolumeListExtensionsTests { - #region FirstWithChapters - - [Fact] - public void FirstWithChapters_ReturnsVolumeWithChapters() - { - var volumes = new List() - { - EntityFactory.CreateVolume("0", new List()), - EntityFactory.CreateVolume("1", new List() - { - EntityFactory.CreateChapter("1", false), - EntityFactory.CreateChapter("2", false), - }), - }; - - Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(false).Number); - Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(true).Number); - } - - [Fact] - public void FirstWithChapters_Book() - { - var volumes = new List() - { - EntityFactory.CreateVolume("1", new List() - { - EntityFactory.CreateChapter("3", false), - EntityFactory.CreateChapter("4", false), - }), - EntityFactory.CreateVolume("0", new List() - { - EntityFactory.CreateChapter("1", false), - EntityFactory.CreateChapter("0", true), - }), - }; - - Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(true).Number); - } - - [Fact] - public void FirstWithChapters_NonBook() - { - var volumes = new List() - { - EntityFactory.CreateVolume("1", new List() - { - EntityFactory.CreateChapter("3", false), - EntityFactory.CreateChapter("4", false), - }), - EntityFactory.CreateVolume("0", new List() - { - EntityFactory.CreateChapter("1", false), - EntityFactory.CreateChapter("0", true), - }), - }; - - Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(false).Number); - } - - #endregion - #region GetCoverImage [Fact] diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index e98cd5730..3632ff9a0 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -31,7 +31,7 @@ namespace API.Tests.Helpers return new Volume() { Name = volumeNumber, - Number = (int) API.Parser.Parser.MinimumNumberFromRange(volumeNumber), + Number = (int) API.Parser.Parser.MinNumberFromRange(volumeNumber), Pages = pages, Chapters = chaps }; @@ -43,7 +43,7 @@ namespace API.Tests.Helpers { IsSpecial = isSpecial, Range = range, - Number = API.Parser.Parser.MinimumNumberFromRange(range) + string.Empty, + Number = API.Parser.Parser.MinNumberFromRange(range) + string.Empty, Files = files ?? new List(), Pages = pageCount, diff --git a/API.Tests/Parser/DefaultParserTests.cs b/API.Tests/Parser/DefaultParserTests.cs index 200a6b16a..f32838dd3 100644 --- a/API.Tests/Parser/DefaultParserTests.cs +++ b/API.Tests/Parser/DefaultParserTests.cs @@ -122,7 +122,7 @@ public class DefaultParserTests filepath = @"E:\Manga\Tenjo Tenge (Color)\Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz"; expected.Add(filepath, new ParserInfo { - Series = "Tenjo Tenge", Volumes = "1", Edition = "Full Contact Edition", + Series = "Tenjo Tenge {Full Contact Edition}", Volumes = "1", Edition = "", Chapters = "0", Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index 34c41015c..a3d298e82 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -169,6 +169,8 @@ namespace API.Tests.Parser [InlineData("Love Hina - Volume 01 [Scans].pdf", "Love Hina")] [InlineData("It's Witching Time! 001 (Digital) (Anonymous1234)", "It's Witching Time!")] [InlineData("Zettai Karen Children v02 c003 - The Invisible Guardian (2) [JS Scans]", "Zettai Karen Children")] + [InlineData("My Charms Are Wasted on Kuroiwa Medaka - Ch. 37.5 - Volume Extras", "My Charms Are Wasted on Kuroiwa Medaka")] + [InlineData("Highschool of the Dead - Full Color Edition v02 [Uasaha] (Yen Press)", "Highschool of the Dead - Full Color Edition")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); @@ -252,13 +254,13 @@ namespace API.Tests.Parser [Theory] [InlineData("Tenjou Tenge Omnibus", "Omnibus")] - [InlineData("Tenjou Tenge {Full Contact Edition}", "Full Contact Edition")] - [InlineData("Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", "Full Contact Edition")] + [InlineData("Tenjou Tenge {Full Contact Edition}", "")] + [InlineData("Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", "")] [InlineData("Wotakoi - Love is Hard for Otaku Omnibus v01 (2018) (Digital) (danke-Empire)", "Omnibus")] [InlineData("To Love Ru v01 Uncensored (Ch.001-007)", "Uncensored")] [InlineData("Chobits Omnibus Edition v01 [Dark Horse]", "Omnibus Edition")] [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "")] - [InlineData("AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz", "Full Color")] + [InlineData("AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz", "")] [InlineData("Love Hina Omnibus v05 (2015) (Digital-HD) (Asgard-Empire).cbz", "Omnibus")] public void ParseEditionTest(string input, string expected) { diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index ff1b4dc71..4ae75d91b 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -63,6 +63,7 @@ namespace API.Tests.Parser [InlineData("- The Title", false, "The Title")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", false, "Kasumi Otoko no Ko v1.1")] [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 04 (2019) (digital) (Son of Ultron-Empire)", true, "Batman - Detective Comics - Rebirth Deluxe Edition")] + [InlineData("Something - Full Color Edition", false, "Something - Full Color Edition")] public void CleanTitleTest(string input, bool isComic, string expected) { Assert.Equal(expected, CleanTitle(input, isComic)); @@ -139,7 +140,7 @@ namespace API.Tests.Parser [InlineData("40.1_a", 0)] public void MinimumNumberFromRangeTest(string input, float expected) { - Assert.Equal(expected, MinimumNumberFromRange(input)); + Assert.Equal(expected, MinNumberFromRange(input)); } [Theory] @@ -152,7 +153,7 @@ namespace API.Tests.Parser [InlineData("40.1_a", 0)] public void MaximumNumberFromRangeTest(string input, float expected) { - Assert.Equal(expected, MaximumNumberFromRange(input)); + Assert.Equal(expected, MaxNumberFromRange(input)); } [Theory] @@ -179,6 +180,7 @@ namespace API.Tests.Parser [InlineData(".test.jpg", false)] [InlineData("!test.jpg", true)] [InlineData("test.webp", true)] + [InlineData("test.gif", true)] public void IsImageTest(string filename, bool expected) { Assert.Equal(expected, IsImage(filename)); @@ -197,6 +199,7 @@ namespace API.Tests.Parser [InlineData("ch1/cover.png", true)] [InlineData("ch1/backcover.png", false)] [InlineData("backcover.png", false)] + [InlineData("back_cover.png", false)] public void IsCoverImageTest(string inputPath, bool expected) { Assert.Equal(expected, IsCoverImage(inputPath)); diff --git a/API.Tests/Services/BookmarkServiceTests.cs b/API.Tests/Services/BookmarkServiceTests.cs index 0026ea678..13acf3684 100644 --- a/API.Tests/Services/BookmarkServiceTests.cs +++ b/API.Tests/Services/BookmarkServiceTests.cs @@ -389,7 +389,7 @@ public class BookmarkServiceTests VolumeId = 1 }, $"{CacheDirectory}1/0001.jpg"); - var files = await bookmarkService.GetBookmarkFilesById(1, new[] {1}); + var files = await bookmarkService.GetBookmarkFilesById(new[] {1}); var actualFiles = ds.GetFiles(BookmarkDirectory, searchOption: SearchOption.AllDirectories); Assert.Equal(files.Select(API.Parser.Parser.NormalizePath).ToList(), actualFiles.Select(API.Parser.Parser.NormalizePath).ToList()); } diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index 7a2cbada8..d5a8d4bee 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -157,7 +157,8 @@ namespace API.Tests.Services filesystem.AddDirectory($"{CacheDirectory}1/"); var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); await ResetDB(); var s = DbFactory.Series("Test"); @@ -240,7 +241,8 @@ namespace API.Tests.Services filesystem.AddFile($"{CacheDirectory}3/003.jpg", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); cleanupService.CleanupChapters(new []{1, 3}); Assert.Empty(ds.GetFiles(CacheDirectory, searchOption:SearchOption.AllDirectories)); @@ -260,7 +262,8 @@ namespace API.Tests.Services filesystem.AddFile($"{DataDirectory}2.epub", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); var c = new Chapter() { @@ -311,7 +314,8 @@ namespace API.Tests.Services var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); // Flatten to prepare for how GetFullPath expects ds.Flatten($"{CacheDirectory}1/"); @@ -362,7 +366,8 @@ namespace API.Tests.Services var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); // Flatten to prepare for how GetFullPath expects ds.Flatten($"{CacheDirectory}1/"); @@ -408,7 +413,8 @@ namespace API.Tests.Services var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); // Flatten to prepare for how GetFullPath expects ds.Flatten($"{CacheDirectory}1/"); @@ -460,7 +466,8 @@ namespace API.Tests.Services var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); // Flatten to prepare for how GetFullPath expects ds.Flatten($"{CacheDirectory}1/"); diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 1dd131112..08d5f29a7 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -11,6 +11,7 @@ using API.Entities.Enums; using API.Services; using API.Services.Tasks; using API.SignalR; +using API.Tests.Helpers; using AutoMapper; using Microsoft.AspNetCore.SignalR; using Microsoft.Data.Sqlite; @@ -125,23 +126,23 @@ public class CleanupServiceTests public async Task DeleteSeriesCoverImages_ShouldDeleteAll() { var filesystem = CreateFileSystem(); - filesystem.AddFile($"{CoverImageDirectory}series_01.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}series_03.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}series_1000.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1)}.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(3)}.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData("")); // Delete all Series to reset state await ResetDB(); var s = DbFactory.Series("Test 1"); - s.CoverImage = "series_01.jpg"; + s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg"; s.LibraryId = 1; _context.Series.Add(s); s = DbFactory.Series("Test 2"); - s.CoverImage = "series_03.jpg"; + s.CoverImage = $"{ImageService.GetSeriesFormat(3)}.jpg"; s.LibraryId = 1; _context.Series.Add(s); s = DbFactory.Series("Test 3"); - s.CoverImage = "series_1000.jpg"; + s.CoverImage = $"{ImageService.GetSeriesFormat(1000)}.jpg"; s.LibraryId = 1; _context.Series.Add(s); @@ -158,20 +159,20 @@ public class CleanupServiceTests public async Task DeleteSeriesCoverImages_ShouldNotDeleteLinkedFiles() { var filesystem = CreateFileSystem(); - filesystem.AddFile($"{CoverImageDirectory}series_01.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}series_03.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}series_1000.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1)}.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(3)}.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData("")); // Delete all Series to reset state await ResetDB(); // Add 2 series with cover images var s = DbFactory.Series("Test 1"); - s.CoverImage = "series_01.jpg"; + s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg"; s.LibraryId = 1; _context.Series.Add(s); s = DbFactory.Series("Test 2"); - s.CoverImage = "series_03.jpg"; + s.CoverImage = $"{ImageService.GetSeriesFormat(3)}.jpg"; s.LibraryId = 1; _context.Series.Add(s); @@ -242,9 +243,9 @@ public class CleanupServiceTests public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles() { var filesystem = CreateFileSystem(); - filesystem.AddFile($"{CoverImageDirectory}tag_01.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}tag_02.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}tag_1000.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData("")); // Delete all Series to reset state await ResetDB(); @@ -255,9 +256,9 @@ public class CleanupServiceTests s.Metadata.CollectionTags.Add(new CollectionTag() { Title = "Something", - CoverImage ="tag_01.jpg" + CoverImage = $"{ImageService.GetCollectionTagFormat(1)}.jpg" }); - s.CoverImage = "series_01.jpg"; + s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg"; s.LibraryId = 1; _context.Series.Add(s); @@ -266,9 +267,9 @@ public class CleanupServiceTests s.Metadata.CollectionTags.Add(new CollectionTag() { Title = "Something 2", - CoverImage ="tag_02.jpg" + CoverImage = $"{ImageService.GetCollectionTagFormat(2)}.jpg" }); - s.CoverImage = "series_03.jpg"; + s.CoverImage = $"{ImageService.GetSeriesFormat(3)}.jpg"; s.LibraryId = 1; _context.Series.Add(s); @@ -285,6 +286,49 @@ public class CleanupServiceTests #endregion + #region DeleteReadingListCoverImages + [Fact] + public async Task DeleteReadingListCoverImages_ShouldNotDeleteLinkedFiles() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetReadingListFormat(1)}.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetReadingListFormat(2)}.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetReadingListFormat(3)}.jpg", new MockFileData("")); + + // Delete all Series to reset state + await ResetDB(); + + _context.Users.Add(new AppUser() + { + UserName = "Joe", + ReadingLists = new List() + { + new ReadingList() + { + Title = "Something", + NormalizedTitle = API.Parser.Parser.Normalize("Something"), + CoverImage = $"{ImageService.GetReadingListFormat(1)}.jpg" + }, + new ReadingList() + { + Title = "Something 2", + NormalizedTitle = API.Parser.Parser.Normalize("Something 2"), + CoverImage = $"{ImageService.GetReadingListFormat(2)}.jpg" + } + } + }); + + await _context.SaveChangesAsync(); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + + await cleanupService.DeleteReadingListCoverImages(); + + Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count()); + } + #endregion + #region CleanupCacheDirectory [Fact] @@ -320,7 +364,7 @@ public class CleanupServiceTests #region CleanupBackups [Fact] - public void CleanupBackups_LeaveOneFile_SinceAllAreExpired() + public async Task CleanupBackups_LeaveOneFile_SinceAllAreExpired() { var filesystem = CreateFileSystem(); var filesystemFile = new MockFileData("") @@ -334,12 +378,12 @@ public class CleanupServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, ds); - cleanupService.CleanupBackups(); + await cleanupService.CleanupBackups(); Assert.Single(ds.GetFiles(BackupDirectory, searchOption: SearchOption.AllDirectories)); } [Fact] - public void CleanupBackups_LeaveLestExpired() + public async Task CleanupBackups_LeaveLestExpired() { var filesystem = CreateFileSystem(); var filesystemFile = new MockFileData("") @@ -356,7 +400,7 @@ public class CleanupServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, ds); - cleanupService.CleanupBackups(); + await cleanupService.CleanupBackups(); Assert.True(filesystem.File.Exists($"{BackupDirectory}randomfile.zip")); } diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 9a4bef08e..23a7dfad1 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -17,6 +17,7 @@ namespace API.Tests.Services { private readonly ILogger _logger = Substitute.For>(); + #region TraverseTreeParallelForEach [Fact] public void TraverseTreeParallelForEach_JustArchives_ShouldBe28() @@ -575,19 +576,22 @@ namespace API.Tests.Services [Fact] public void CopyFilesToDirectory_ShouldAppendWhenTargetFileExists() { + const string testDirectory = "/manga/"; var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file.zip", new MockFileData("")); - fileSystem.AddFile($"/manga/output/file (1).zip", new MockFileData("")); - fileSystem.AddFile($"/manga/output/file (2).zip", new MockFileData("")); + fileSystem.AddFile(MockUnixSupport.Path($"{testDirectory}file.zip"), new MockFileData("")); + fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (1).zip"), new MockFileData("")); + fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (2).zip"), new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFilesToDirectory(new []{$"{testDirectory}file.zip"}, "/manga/output/"); - ds.CopyFilesToDirectory(new []{$"{testDirectory}file.zip"}, "/manga/output/"); + ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/"); + ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/"); var outputFiles = ds.GetFiles("/manga/output/").Select(API.Parser.Parser.NormalizePath).ToList(); Assert.Equal(4, outputFiles.Count()); // we have 2 already there and 2 copies - // For some reason, this has C:/ on directory even though everything is emulated - Assert.True(outputFiles.Contains(API.Parser.Parser.NormalizePath("/manga/output/file (3).zip")) || outputFiles.Contains(API.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip"))); + // For some reason, this has C:/ on directory even though everything is emulated (System.IO.Abstractions issue, not changing) + // https://github.com/TestableIO/System.IO.Abstractions/issues/831 + Assert.True(outputFiles.Contains(API.Parser.Parser.NormalizePath("/manga/output/file (3).zip")) + || outputFiles.Contains(API.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip"))); } #endregion diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 8439c69d3..cfb89935a 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -11,6 +11,7 @@ using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Services; +using API.SignalR; using API.Tests.Helpers; using AutoMapper; using Microsoft.Data.Sqlite; @@ -147,7 +148,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); Assert.Equal(0, await readerService.CapPageToChapter(1, -1)); Assert.Equal(1, await readerService.CapPageToChapter(1, 10)); @@ -191,7 +192,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var successful = await readerService.SaveReadingProgress(new ProgressDto() { @@ -240,7 +241,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var successful = await readerService.SaveReadingProgress(new ProgressDto() { @@ -310,7 +311,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var volumes = await _unitOfWork.VolumeRepository.GetVolumes(1); readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); @@ -360,7 +361,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); @@ -420,7 +421,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 1, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); @@ -466,7 +467,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); @@ -508,7 +509,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 4, 1); @@ -551,7 +552,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 4, 1); @@ -587,7 +588,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); @@ -628,7 +629,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); @@ -669,7 +670,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); @@ -708,7 +709,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); @@ -751,7 +752,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 3, 1); @@ -793,7 +794,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 3, 1); @@ -846,7 +847,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); @@ -892,7 +893,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1); @@ -934,7 +935,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1); @@ -972,7 +973,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); @@ -1007,7 +1008,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); @@ -1047,7 +1048,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); @@ -1095,7 +1096,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2,5, 1); var chapterInfoDto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter); @@ -1137,7 +1138,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); @@ -1178,7 +1179,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 4, 1); @@ -1221,7 +1222,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); @@ -1276,7 +1277,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetContinuePoint(1, 1); @@ -1321,7 +1322,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); // Save progress on first volume chapters and 1st of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -1404,7 +1405,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); // Save progress on first volume and 1st chapter of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -1470,7 +1471,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); // Save progress on first volume chapters and 1st of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -1538,7 +1539,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var nextChapter = await readerService.GetContinuePoint(1, 1); Assert.Equal("1", nextChapter.Range); @@ -1575,7 +1576,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); // Save progress on first volume chapters and 1st of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -1640,7 +1641,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); // Save progress on first volume chapters and 1st of second volume var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); @@ -1681,7 +1682,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); // Save progress on first volume chapters and 1st of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -1753,7 +1754,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); await readerService.MarkSeriesAsRead(user, 1); await _context.SaveChangesAsync(); @@ -1801,7 +1802,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await readerService.MarkChaptersUntilAsRead(user, 1, 5); @@ -1844,7 +1845,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await readerService.MarkChaptersUntilAsRead(user, 1, 2.5f); @@ -1888,7 +1889,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await readerService.MarkChaptersUntilAsRead(user, 1, 2); @@ -1947,7 +1948,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); const int markReadUntilNumber = 47; @@ -2027,7 +2028,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); await readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1); await _context.SaveChangesAsync(); @@ -2078,7 +2079,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var readerService = new ReaderService(_unitOfWork, Substitute.For>()); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 280fe5c10..e3331bf6d 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -125,5 +125,6 @@ namespace API.Tests.Services // } + } } diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 1b9f5fd3f..217eb63e0 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -22,6 +22,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Xunit.Sdk; namespace API.Tests.Services; @@ -703,6 +704,85 @@ public class SeriesServiceTests Assert.False(series.Metadata.GenresLocked); // GenreLocked is false unless the UI Explicitly says it should be locked } + [Fact] + public async Task UpdateSeriesMetadata_ShouldAddNewPerson_NoExistingPeople() + { + await ResetDb(); + var s = new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + }, + Metadata = DbFactory.SeriesMetadata(new List()) + }; + var g = DbFactory.Person("Existing Person", PersonRole.Publisher); + _context.Series.Add(s); + + _context.Person.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + { + SeriesMetadata = new SeriesMetadataDto() + { + SeriesId = 1, + Publishers = new List() {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, + }, + CollectionTags = new List() + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series.Metadata); + Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person")); + Assert.False(series.Metadata.PublisherLocked); // PublisherLocked is false unless the UI Explicitly says it should be locked + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldAddNewPerson_ExistingPeople() + { + await ResetDb(); + var s = new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + }, + Metadata = DbFactory.SeriesMetadata(new List()) + }; + var g = DbFactory.Person("Existing Person", PersonRole.Publisher); + s.Metadata.People = new List() {DbFactory.Person("Existing Writer", PersonRole.Writer), + DbFactory.Person("Existing Translator", PersonRole.Translator), DbFactory.Person("Existing Publisher 2", PersonRole.Publisher)}; + _context.Series.Add(s); + + _context.Person.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + { + SeriesMetadata = new SeriesMetadataDto() + { + SeriesId = 1, + Publishers = new List() {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, + PublishersLocked = true + }, + CollectionTags = new List() + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series.Metadata); + Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person")); + Assert.True(series.Metadata.PublisherLocked); + } + [Fact] public async Task UpdateSeriesMetadata_ShouldLockIfTold() { @@ -745,4 +825,86 @@ public class SeriesServiceTests } #endregion + + #region GetFirstChapterForMetadata + + private static Series CreateSeriesMock() + { + var files = new List() + { + EntityFactory.CreateMangaFile("Test.cbz", MangaFormat.Archive, 1) + }; + return new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("95", false, files, 1), + EntityFactory.CreateChapter("96", false, files, 1), + EntityFactory.CreateChapter("A Special Case", true, files, 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, files, 1), + EntityFactory.CreateChapter("2", false, files, 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, files, 1), + EntityFactory.CreateChapter("22", false, files, 1), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, files, 1), + EntityFactory.CreateChapter("32", false, files, 1), + }), + } + }; + } + + [Fact] + public void GetFirstChapterForMetadata_Book_Test() + { + var series = CreateSeriesMock(); + + var firstChapter = SeriesService.GetFirstChapterForMetadata(series, true); + Assert.Same("1", firstChapter.Range); + } + + [Fact] + public void GetFirstChapterForMetadata_NonBook_ShouldReturnVolume1() + { + var series = CreateSeriesMock(); + + var firstChapter = SeriesService.GetFirstChapterForMetadata(series, false); + Assert.Same("1", firstChapter.Range); + } + + [Fact] + public void GetFirstChapterForMetadata_NonBook_ShouldReturnVolume1_WhenFirstChapterIsFloat() + { + var series = CreateSeriesMock(); + var files = new List() + { + EntityFactory.CreateMangaFile("Test.cbz", MangaFormat.Archive, 1) + }; + series.Volumes[1].Chapters = new List() + { + EntityFactory.CreateChapter("2", false, files, 1), + EntityFactory.CreateChapter("1.1", false, files, 1), + EntityFactory.CreateChapter("1.2", false, files, 1), + }; + + var firstChapter = SeriesService.GetFirstChapterForMetadata(series, false); + Assert.Same("1.1", firstChapter.Range); + } + + #endregion } diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs index 3f3f18acf..246461fc8 100644 --- a/API.Tests/Services/SiteThemeServiceTests.cs +++ b/API.Tests/Services/SiteThemeServiceTests.cs @@ -25,7 +25,7 @@ namespace API.Tests.Services; public class SiteThemeServiceTests { - private readonly ILogger _logger = Substitute.For>(); + private readonly ILogger _logger = Substitute.For>(); private readonly IEventHub _messageHub = Substitute.For(); private readonly DbConnection _connection; @@ -87,7 +87,7 @@ public class SiteThemeServiceTests UserName = "Joe", UserPreferences = new AppUserPreferences { - Theme = Seed.DefaultThemes[1] + Theme = Seed.DefaultThemes[0] } }); @@ -135,7 +135,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); await siteThemeService.Scan(); Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); @@ -148,7 +148,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); await siteThemeService.Scan(); Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); @@ -167,7 +167,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); await siteThemeService.Scan(); Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); @@ -188,7 +188,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); _context.SiteTheme.Add(new SiteTheme() { @@ -213,7 +213,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); _context.SiteTheme.Add(new SiteTheme() { @@ -241,7 +241,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); _context.SiteTheme.Add(new SiteTheme() { diff --git a/API.Tests/Services/Test Data/ArchiveService/Archives/macos_native.zip b/API.Tests/Services/Test Data/ArchiveService/Archives/macos_native.zip index a84f32b35..c47021fd2 100644 Binary files a/API.Tests/Services/Test Data/ArchiveService/Archives/macos_native.zip and b/API.Tests/Services/Test Data/ArchiveService/Archives/macos_native.zip differ diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.zip b/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.zip index a84f32b35..c47021fd2 100644 Binary files a/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.zip and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.zip differ diff --git a/API/.dockerignore b/API/.dockerignore deleted file mode 100644 index cd967fc3a..000000000 --- a/API/.dockerignore +++ /dev/null @@ -1,25 +0,0 @@ -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/.idea -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/bin -**/charts -**/docker-compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md \ No newline at end of file diff --git a/API/API.csproj b/API/API.csproj index 492430a44..1099893a3 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -40,39 +40,39 @@ - - + + - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + @@ -94,16 +94,12 @@ - - - - diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index dfa30b18d..cc0b66ec1 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -353,6 +353,7 @@ namespace API.Controllers _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); // Check if there is an existing invite + dto.Email = dto.Email.Trim(); var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); if (emailValidationErrors.Any()) { @@ -454,6 +455,11 @@ namespace API.Controllers { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (user == null) + { + return BadRequest("The email does not match the registered email"); + } + // Validate Password and Username var validationErrors = new List(); validationErrors.AddRange(await _accountService.ValidateUsername(dto.Username)); diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 89b2d3de4..7b4b49a9f 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -40,7 +40,7 @@ namespace API.Controllers if (dto.SeriesFormat == MangaFormat.Epub) { var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); - using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath); + using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions); bookTitle = book.Title; } @@ -63,7 +63,7 @@ namespace API.Controllers public async Task GetBookPageResources(int chapterId, [FromQuery] string file) { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); var key = BookService.CleanContentKeys(file); if (!book.Content.AllFiles.ContainsKey(key)) return BadRequest("File was not found in book"); @@ -87,7 +87,7 @@ namespace API.Controllers public async Task>> GetBookChapters(int chapterId) { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); var mappings = await _bookService.CreateKeyToPageMappingAsync(book); var navItems = await book.GetNavigationAsync(); @@ -211,8 +211,7 @@ namespace API.Controllers var chapter = await _cacheService.Ensure(chapterId); var path = _cacheService.GetCachedEpubFile(chapter.Id, chapter); - - using var book = await EpubReader.OpenBookAsync(path); + using var book = await EpubReader.OpenBookAsync(path, BookService.BookReaderOptions); var mappings = await _bookService.CreateKeyToPageMappingAsync(book); var counter = 0; diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 6abc22955..a98e28952 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -157,7 +157,7 @@ namespace API.Controllers tag.CoverImageLocked = false; tag.CoverImage = string.Empty; await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag"), false); + MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false); _unitOfWork.CollectionTagRepository.Update(tag); } diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index b60fae6e8..433f16721 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -171,7 +171,7 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId); - var files = await _bookmarkService.GetBookmarkFilesById(user.Id, downloadBookmarkDto.Bookmarks.Select(b => b.Id)); + var files = await _bookmarkService.GetBookmarkFilesById(downloadBookmarkDto.Bookmarks.Select(b => b.Id)); var filename = $"{series.Name} - Bookmarks.zip"; await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 8b58fe9b3..2393d0ea6 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -88,6 +88,22 @@ namespace API.Controllers return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } + /// + /// Returns cover image for a Reading List + /// + /// + /// + [HttpGet("readinglist-cover")] + public async Task GetReadingListCoverImage(int readingListId) + { + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + Response.AddCacheHeader(path); + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); + } + /// /// Returns image for a given bookmark page /// diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 39c7264d2..ea87456c0 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -99,12 +99,12 @@ public class MetadataController : BaseApiController /// String separated libraryIds or null for all publication status /// [HttpGet("publication-status")] - public async Task>> GetAllPublicationStatus(string? libraryIds) + public ActionResult> GetAllPublicationStatus(string? libraryIds) { var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); - if (ids != null && ids.Count > 0) + if (ids is {Count: > 0}) { - return Ok(await _unitOfWork.SeriesRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids)); + return Ok(_unitOfWork.SeriesRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids)); } return Ok(Enum.GetValues().Select(t => new PublicationStatusDto() diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index eaa778121..a221f06c1 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -389,12 +389,8 @@ public class OpdsController : BaseApiController var userParams = new UserParams() { PageNumber = pageNumber, - PageSize = 20 }; - var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto); - var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize) - .Take(userParams.PageSize).ToList(); - var pagedList = new PagedList(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize); + var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto); Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index e2d067abd..7ced84d6a 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -11,6 +11,8 @@ using API.Entities; using API.Extensions; using API.Services; using API.Services.Tasks; +using API.SignalR; +using Hangfire; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -25,23 +27,21 @@ namespace API.Controllers private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IReaderService _readerService; - private readonly IDirectoryService _directoryService; - private readonly ICleanupService _cleanupService; private readonly IBookmarkService _bookmarkService; + private readonly IEventHub _eventHub; /// public ReaderController(ICacheService cacheService, IUnitOfWork unitOfWork, ILogger logger, - IReaderService readerService, IDirectoryService directoryService, - ICleanupService cleanupService, IBookmarkService bookmarkService) + IReaderService readerService, IBookmarkService bookmarkService, + IEventHub eventHub) { _cacheService = cacheService; _unitOfWork = unitOfWork; _logger = logger; _readerService = readerService; - _directoryService = directoryService; - _cleanupService = cleanupService; _bookmarkService = bookmarkService; + _eventHub = eventHub; } /// @@ -73,6 +73,41 @@ namespace API.Controllers } } + /// + /// Returns an image for a given bookmark series. Side effect: This will cache the bookmark images for reading. + /// + /// + /// Api key for the user the bookmarks are on + /// + /// We must use api key as bookmarks could be leaked to other users via the API + /// + [HttpGet("bookmark-image")] + public async Task GetBookmarkImage(int seriesId, string apiKey, int page) + { + if (page < 0) page = 0; + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId); + if (page > totalPages) + { + page = totalPages; + } + + try + { + var path = _cacheService.GetCachedBookmarkPagePath(seriesId, page); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}"); + var format = Path.GetExtension(path).Replace(".", ""); + + Response.AddCacheHeader(path, TimeSpan.FromMinutes(10).Seconds); + return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); + } + catch (Exception) + { + _cacheService.CleanupBookmarks(new []{ seriesId }); + throw; + } + } + /// /// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading. /// @@ -81,6 +116,7 @@ namespace API.Controllers [HttpGet("chapter-info")] public async Task> GetChapterInfo(int chapterId) { + if (chapterId <= 0) return null; // This can happen occasionally from UI, we should just ignore var chapter = await _cacheService.Ensure(chapterId); if (chapter == null) return BadRequest("Could not find Chapter"); @@ -104,6 +140,28 @@ namespace API.Controllers }); } + /// + /// Returns various information about all bookmark files for a Series. Side effect: This will cache the bookmark images for reading. + /// + /// Series Id for all bookmarks + /// + [HttpGet("bookmark-info")] + public async Task> GetBookmarkInfo(int seriesId) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var totalPages = await _cacheService.CacheBookmarkForSeries(user.Id, seriesId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None); + + return Ok(new BookmarkInfoDto() + { + SeriesName = series.Name, + SeriesFormat = series.Format, + SeriesId = series.Id, + LibraryId = series.LibraryId, + Pages = totalPages, + }); + } + [HttpPost("mark-read")] public async Task MarkRead(MarkReadDto markReadDto) @@ -111,13 +169,9 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId); - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } + if (!await _unitOfWork.CommitAsync()) return BadRequest("There was an issue saving progress"); - - return BadRequest("There was an issue saving progress"); + return Ok(); } @@ -132,13 +186,9 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId); - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } + if (!await _unitOfWork.CommitAsync()) return BadRequest("There was an issue saving progress"); - - return BadRequest("There was an issue saving progress"); + return Ok(); } /// @@ -514,6 +564,7 @@ namespace API.Controllers if (await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) { + BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); return Ok(); } @@ -533,6 +584,7 @@ namespace API.Controllers if (await _bookmarkService.RemoveBookmarkPage(user, bookmarkDto)) { + BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); return Ok(); } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 83cdc1a04..1b72b20d2 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -7,6 +7,7 @@ using API.DTOs.ReadingLists; using API.Entities; using API.Extensions; using API.Helpers; +using API.SignalR; using Microsoft.AspNetCore.Mvc; namespace API.Controllers @@ -14,11 +15,13 @@ namespace API.Controllers public class ReadingListController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - public ReadingListController(IUnitOfWork unitOfWork) + public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub) { _unitOfWork = unitOfWork; + _eventHub = eventHub; } /// @@ -208,12 +211,8 @@ namespace API.Controllers { return BadRequest("A list of this name already exists"); } - user.ReadingLists.Add(new ReadingList() - { - Promoted = false, - Title = dto.Title, - Summary = string.Empty - }); + + user.ReadingLists.Add(DbFactory.ReadingList(dto.Title, string.Empty, false)); if (!_unitOfWork.HasChanges()) return BadRequest("There was a problem creating list"); @@ -233,9 +232,12 @@ namespace API.Controllers var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); if (readingList == null) return BadRequest("List does not exist"); + + if (!string.IsNullOrEmpty(dto.Title)) { readingList.Title = dto.Title; // Should I check if this is unique? + readingList.NormalizedTitle = Parser.Parser.Normalize(readingList.Title); } if (!string.IsNullOrEmpty(dto.Title)) { @@ -244,6 +246,19 @@ namespace API.Controllers readingList.Promoted = dto.Promoted; + readingList.CoverImageLocked = dto.CoverImageLocked; + + if (!dto.CoverImageLocked) + { + readingList.CoverImageLocked = false; + readingList.CoverImage = string.Empty; + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(readingList.Id, MessageFactoryEntityTypes.ReadingList), false); + _unitOfWork.ReadingListRepository.Update(readingList); + } + + + _unitOfWork.ReadingListRepository.Update(readingList); if (await _unitOfWork.CommitAsync()) @@ -455,14 +470,7 @@ namespace API.Controllers foreach (var chapter in chaptersForSeries) { if (existingChapterExists.Contains(chapter.Id)) continue; - - readingList.Items.Add(new ReadingListItem() - { - Order = index, - ChapterId = chapter.Id, - SeriesId = seriesId, - VolumeId = chapter.VolumeId - }); + readingList.Items.Add(DbFactory.ReadingListItem(index, seriesId, chapter.VolumeId, chapter.Id)); index += 1; } diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs new file mode 100644 index 000000000..acd200b97 --- /dev/null +++ b/API/Controllers/RecommendedController.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Extensions; +using API.Helpers; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +public class RecommendedController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + + public RecommendedController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + + /// + /// Quick Reads are series that are less than 2K pages in total. + /// + /// Library to restrict series to + /// + [HttpGet("quick-reads")] + public async Task>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + userParams ??= new UserParams(); + var series = await _unitOfWork.SeriesRepository.GetQuickReads(user.Id, libraryId, userParams); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users. + /// + /// Library to restrict series to + /// + [HttpGet("highly-rated")] + public async Task>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + userParams ??= new UserParams(); + var series = await _unitOfWork.SeriesRepository.GetHighlyRated(user.Id, libraryId, userParams); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Chooses a random genre and shows series that are in that without reading progress + /// + /// Library to restrict series to + /// + [HttpGet("more-in")] + public async Task>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + userParams ??= new UserParams(); + var series = await _unitOfWork.SeriesRepository.GetMoreIn(user.Id, libraryId, genreId, userParams); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Series that are fully read by the user in no particular order + /// + /// Library to restrict series to + /// + [HttpGet("rediscover")] + public async Task>> GetRediscover(int libraryId, [FromQuery] UserParams userParams) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + userParams ??= new UserParams(); + var series = await _unitOfWork.SeriesRepository.GetRediscover(user.Id, libraryId, userParams); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + +} diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 3aad34d99..34e90d818 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -6,8 +6,10 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; +using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Helpers; using API.Services; @@ -214,13 +216,6 @@ namespace API.Controllers return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId)); } - [HttpPost("recently-added-chapters")] - public async Task>> GetRecentlyAddedChaptersAlt() - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetRecentlyAddedChapters(userId)); - } - [HttpPost("all")] public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { @@ -248,12 +243,8 @@ namespace API.Controllers [HttpPost("on-deck")] public async Task>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { - // NOTE: This has to be done manually like this due to the DistinctBy requirement var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto); - - var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize).Take(userParams.PageSize).ToList(); - var pagedList = new PagedList(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize); + var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto); await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList); @@ -346,5 +337,105 @@ namespace API.Controllers var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return await _seriesService.GetSeriesDetail(seriesId, userId); } + + /// + /// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI), + /// then null is returned + /// + /// + /// + [HttpGet("series-for-mangafile")] + public async Task> GetSeriesForMangaFile(int mangaFileId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId)); + } + + /// + /// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI), + /// then null is returned + /// + /// + /// + [HttpGet("series-for-chapter")] + public async Task> GetSeriesForChapter(int chapterId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId)); + } + + /// + /// Fetches the related series for a given series + /// + /// + /// Type of Relationship to pull back + /// + [HttpGet("related")] + public async Task>> GetRelatedSeries(int seriesId, RelationKind relation) + { + // Send back a custom DTO with each type or maybe sorted in some way + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation)); + } + + [HttpGet("all-related")] + public async Task> GetAllRelatedSeries(int seriesId) + { + // Send back a custom DTO with each type or maybe sorted in some way + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId)); + } + + [Authorize(Policy="RequireAdminRole")] + [HttpPost("update-related")] + public async Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Related); + + UpdateRelationForKind(dto.Adaptations, series.Relations.Where(r => r.RelationKind == RelationKind.Adaptation).ToList(), series, RelationKind.Adaptation); + UpdateRelationForKind(dto.Characters, series.Relations.Where(r => r.RelationKind == RelationKind.Character).ToList(), series, RelationKind.Character); + UpdateRelationForKind(dto.Contains, series.Relations.Where(r => r.RelationKind == RelationKind.Contains).ToList(), series, RelationKind.Contains); + UpdateRelationForKind(dto.Others, series.Relations.Where(r => r.RelationKind == RelationKind.Other).ToList(), series, RelationKind.Other); + UpdateRelationForKind(dto.SideStories, series.Relations.Where(r => r.RelationKind == RelationKind.SideStory).ToList(), series, RelationKind.SideStory); + UpdateRelationForKind(dto.SpinOffs, series.Relations.Where(r => r.RelationKind == RelationKind.SpinOff).ToList(), series, RelationKind.SpinOff); + UpdateRelationForKind(dto.AlternativeSettings, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeSetting).ToList(), series, RelationKind.AlternativeSetting); + UpdateRelationForKind(dto.AlternativeVersions, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeVersion).ToList(), series, RelationKind.AlternativeVersion); + UpdateRelationForKind(dto.Doujinshis, series.Relations.Where(r => r.RelationKind == RelationKind.Doujinshi).ToList(), series, RelationKind.Doujinshi); + UpdateRelationForKind(dto.Prequels, series.Relations.Where(r => r.RelationKind == RelationKind.Prequel).ToList(), series, RelationKind.Prequel); + UpdateRelationForKind(dto.Sequels, series.Relations.Where(r => r.RelationKind == RelationKind.Sequel).ToList(), series, RelationKind.Sequel); + + if (!_unitOfWork.HasChanges()) return Ok(); + if (await _unitOfWork.CommitAsync()) return Ok(); + + + return BadRequest("There was an issue updating relationships"); + } + + private void UpdateRelationForKind(IList dtoTargetSeriesIds, IEnumerable adaptations, Series series, RelationKind kind) + { + foreach (var adaptation in adaptations.Where(adaptation => !dtoTargetSeriesIds.Contains(adaptation.TargetSeriesId))) + { + // If the seriesId isn't in dto, it means we've removed or reclassified + series.Relations.Remove(adaptation); + } + + // At this point, we only have things to add + foreach (var targetSeriesId in dtoTargetSeriesIds) + { + // This ensures we don't allow any duplicates to be added + if (series.Relations.SingleOrDefault(r => + r.RelationKind == kind && r.TargetSeriesId == targetSeriesId) != + null) continue; + + series.Relations.Add(new SeriesRelation() + { + Series = series, + SeriesId = series.Id, + TargetSeriesId = targetSeriesId, + RelationKind = kind + }); + _unitOfWork.SeriesRepository.Update(series); + } + } } } diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs index f6775d2dc..bf68e8641 100644 --- a/API/Controllers/ThemeController.cs +++ b/API/Controllers/ThemeController.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Theme; +using API.Extensions; using API.Services; using API.Services.Tasks; using Kavita.Common; @@ -13,13 +14,13 @@ namespace API.Controllers; public class ThemeController : BaseApiController { private readonly IUnitOfWork _unitOfWork; - private readonly ISiteThemeService _siteThemeService; + private readonly IThemeService _themeService; private readonly ITaskScheduler _taskScheduler; - public ThemeController(IUnitOfWork unitOfWork, ISiteThemeService siteThemeService, ITaskScheduler taskScheduler) + public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, ITaskScheduler taskScheduler) { _unitOfWork = unitOfWork; - _siteThemeService = siteThemeService; + _themeService = themeService; _taskScheduler = taskScheduler; } @@ -39,9 +40,9 @@ public class ThemeController : BaseApiController [Authorize("RequireAdminRole")] [HttpPost("update-default")] - public async Task UpdateDefault(UpdateDefaultSiteThemeDto dto) + public async Task UpdateDefault(UpdateDefaultThemeDto dto) { - await _siteThemeService.UpdateDefault(dto.ThemeId); + await _themeService.UpdateDefault(dto.ThemeId); return Ok(); } @@ -54,7 +55,7 @@ public class ThemeController : BaseApiController { try { - return Ok(await _siteThemeService.GetContent(themeId)); + return Ok(await _themeService.GetContent(themeId)); } catch (KavitaException ex) { diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 4d07d4225..ca84acc8b 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -5,6 +5,7 @@ using API.Data; using API.DTOs.Uploads; using API.Extensions; using API.Services; +using API.SignalR; using Flurl.Http; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,16 +25,18 @@ namespace API.Controllers private readonly ILogger _logger; private readonly ITaskScheduler _taskScheduler; private readonly IDirectoryService _directoryService; + private readonly IEventHub _eventHub; /// public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, - ITaskScheduler taskScheduler, IDirectoryService directoryService) + ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub) { _unitOfWork = unitOfWork; _imageService = imageService; _logger = logger; _taskScheduler = taskScheduler; _directoryService = directoryService; + _eventHub = eventHub; } /// @@ -99,6 +102,8 @@ namespace API.Controllers if (_unitOfWork.HasChanges()) { + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); await _unitOfWork.CommitAsync(); return Ok(); } @@ -145,6 +150,8 @@ namespace API.Controllers if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false); return Ok(); } @@ -158,6 +165,53 @@ namespace API.Controllers return BadRequest("Unable to save cover image to Collection Tag"); } + /// + /// Replaces reading list cover image and locks it with a base64 encoded image + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(8_000_000)] + [HttpPost("reading-list")] + public async Task UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) + { + // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // See if we can do this all in memory without touching underlying system + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + return BadRequest("You must pass a url to use"); + } + + try + { + var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); + + if (!string.IsNullOrEmpty(filePath)) + { + readingList.CoverImage = filePath; + readingList.CoverImageLocked = true; + _unitOfWork.ReadingListRepository.Update(readingList); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(readingList.Id, MessageFactoryEntityTypes.ReadingList), false); + return Ok(); + } + + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue uploading cover image for Reading List {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest("Unable to save cover image to Reading List"); + } + /// /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. /// @@ -193,6 +247,10 @@ namespace API.Controllers if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), false); return Ok(); } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 3fda79468..97dae76a4 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -82,11 +82,13 @@ namespace API.Controllers existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin; existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing; existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily; - existingPreferences.BookReaderDarkMode = preferencesDto.BookReaderDarkMode; existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; preferencesDto.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName; + existingPreferences.PageLayoutMode = preferencesDto.BookReaderLayoutMode; + existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode; existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id); // TODO: Remove this code - this overrides layout mode to be single until the mode is released diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs index 42d4bdf8e..9d0d9416d 100644 --- a/API/DTOs/Account/InviteUserDto.cs +++ b/API/DTOs/Account/InviteUserDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.Account; public class InviteUserDto { [Required] - public string Email { get; init; } + public string Email { get; set; } /// /// List of Roles to assign to user. If admin not present, Pleb will be applied. /// If admin present, all libraries will be granted access and will ignore those from DTO. diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index 1a8d9fc8b..892f9e6b9 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -99,6 +99,5 @@ namespace API.DTOs.Filtering /// An optional name string to filter by. Empty string will ignore. /// public string SeriesNameQuery { get; init; } = string.Empty; - } } diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs index 0e465f6aa..3d78494bd 100644 --- a/API/DTOs/Filtering/SortField.cs +++ b/API/DTOs/Filtering/SortField.cs @@ -5,4 +5,5 @@ public enum SortField SortName = 1, CreatedDate = 2, LastModifiedDate = 3, + LastChapterAdded = 4 } diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index 786f85df7..c1220e28d 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -4,9 +4,10 @@ namespace API.DTOs { public class MangaFileDto { + public int Id { get; init; } public string FilePath { get; init; } public int Pages { get; init; } public MangaFormat Format { get; init; } - + } -} \ No newline at end of file +} diff --git a/API/DTOs/Reader/BookmarkInfoDto.cs b/API/DTOs/Reader/BookmarkInfoDto.cs new file mode 100644 index 000000000..a34eb81c2 --- /dev/null +++ b/API/DTOs/Reader/BookmarkInfoDto.cs @@ -0,0 +1,13 @@ +using API.Entities.Enums; + +namespace API.DTOs.Reader; + +public class BookmarkInfoDto +{ + public string SeriesName { get; set; } + public MangaFormat SeriesFormat { get; set; } + public int SeriesId { get; set; } + public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } + public int Pages { get; set; } +} diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index e3837a2e3..3eb5ded79 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -9,5 +9,6 @@ /// Reading lists that are promoted are only done by admins /// public bool Promoted { get; set; } + public bool CoverImageLocked { get; set; } } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListDto.cs b/API/DTOs/ReadingLists/UpdateReadingListDto.cs index a9f6f0d59..5b8f69731 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListDto.cs @@ -6,5 +6,6 @@ public string Title { get; set; } public string Summary { get; set; } public bool Promoted { get; set; } + public bool CoverImageLocked { get; set; } } } diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index b21209dca..0a1fac402 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -17,5 +17,8 @@ public class SearchResultGroupDto public IEnumerable Persons { get; set; } public IEnumerable Genres { get; set; } public IEnumerable Tags { get; set; } + public IEnumerable Files { get; set; } + public IEnumerable Chapters { get; set; } + } diff --git a/API/DTOs/SeriesDetail/RelatedSeriesDto.cs b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs new file mode 100644 index 000000000..f3c3fd644 --- /dev/null +++ b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.SeriesDetail; + +public class RelatedSeriesDto +{ + /// + /// The parent relationship Series + /// + public int SourceSeriesId { get; set; } + + public IEnumerable Sequels { get; set; } + public IEnumerable Prequels { get; set; } + public IEnumerable SpinOffs { get; set; } + public IEnumerable Adaptations { get; set; } + public IEnumerable SideStories { get; set; } + public IEnumerable Characters { get; set; } + public IEnumerable Contains { get; set; } + public IEnumerable Others { get; set; } + public IEnumerable AlternativeSettings { get; set; } + public IEnumerable AlternativeVersions { get; set; } + public IEnumerable Doujinshis { get; set; } + public IEnumerable Parent { get; set; } +} diff --git a/API/DTOs/SeriesDetailDto.cs b/API/DTOs/SeriesDetail/SeriesDetailDto.cs similarity index 100% rename from API/DTOs/SeriesDetailDto.cs rename to API/DTOs/SeriesDetail/SeriesDetailDto.cs diff --git a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs new file mode 100644 index 000000000..b39f91244 --- /dev/null +++ b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace API.DTOs.SeriesDetail; + +public class UpdateRelatedSeriesDto +{ + public int SeriesId { get; set; } + public IList Adaptations { get; set; } + public IList Characters { get; set; } + public IList Contains { get; set; } + public IList Others { get; set; } + public IList Prequels { get; set; } + public IList Sequels { get; set; } + public IList SideStories { get; set; } + public IList SpinOffs { get; set; } + public IList AlternativeSettings { get; set; } + public IList AlternativeVersions { get; set; } + public IList Doujinshis { get; set; } +} diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index 5f76634ff..a5756ceca 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -22,6 +22,10 @@ namespace API.DTOs /// public DateTime LatestReadDate { get; set; } /// + /// DateTime representing last time a chapter was added to the Series + /// + public DateTime LastChapterAdded { get; set; } + /// /// Rating from logged in user. Calculated at API-time. /// public int UserRating { get; set; } diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index d5ad24610..9a396f5d1 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -45,11 +45,11 @@ namespace API.DTOs /// public string Language { get; set; } = string.Empty; /// - /// Number in the TotalCount of issues + /// Max number of issues/volumes in the series (Max of Volume/Issue field in ComicInfo) /// - public int Count { get; set; } + public int MaxCount { get; set; } = 0; /// - /// Total number of issues for the series + /// Total number of issues/volumes for the series /// public int TotalCount { get; set; } /// @@ -69,16 +69,16 @@ namespace API.DTOs public bool PublicationStatusLocked { get; set; } public bool GenresLocked { get; set; } public bool TagsLocked { get; set; } - public bool WriterLocked { get; set; } - public bool CharacterLocked { get; set; } - public bool ColoristLocked { get; set; } - public bool EditorLocked { get; set; } - public bool InkerLocked { get; set; } - public bool LettererLocked { get; set; } - public bool PencillerLocked { get; set; } - public bool PublisherLocked { get; set; } - public bool TranslatorLocked { get; set; } - public bool CoverArtistLocked { get; set; } + public bool WritersLocked { get; set; } + public bool CharactersLocked { get; set; } + public bool ColoristsLocked { get; set; } + public bool EditorsLocked { get; set; } + public bool InkersLocked { get; set; } + public bool LetterersLocked { get; set; } + public bool PencillersLocked { get; set; } + public bool PublishersLocked { get; set; } + public bool TranslatorsLocked { get; set; } + public bool CoverArtistsLocked { get; set; } public int SeriesId { get; set; } diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs index e8b0460f9..7c44a1cd0 100644 --- a/API/DTOs/Theme/SiteThemeDto.cs +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -4,6 +4,9 @@ using API.Services; namespace API.DTOs.Theme; +/// +/// Represents a set of css overrides the user can upload to Kavita and will load into webui +/// public class SiteThemeDto { public int Id { get; set; } diff --git a/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs b/API/DTOs/Theme/UpdateDefaultThemeDto.cs similarity index 64% rename from API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs rename to API/DTOs/Theme/UpdateDefaultThemeDto.cs index d4bdb8e09..0f2b129f3 100644 --- a/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs +++ b/API/DTOs/Theme/UpdateDefaultThemeDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Theme; -public class UpdateDefaultSiteThemeDto +public class UpdateDefaultThemeDto { public int ThemeId { get; set; } } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 4bfcb2d77..4fc2f6904 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -1,4 +1,5 @@ -using API.Entities; +using API.DTOs.Theme; +using API.Entities; using API.Entities.Enums; namespace API.DTOs @@ -74,5 +75,12 @@ namespace API.DTOs /// /// Should default to Dark public SiteTheme Theme { get; set; } + public string BookReaderThemeName { get; set; } + public BookPageLayoutMode BookReaderLayoutMode { get; set; } + /// + /// 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. + /// + /// Defaults to false + public bool BookReaderImmersiveMode { get; set; } = false; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 6822467a8..154cdf2fc 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -41,6 +41,7 @@ namespace API.Data public DbSet Genre { get; set; } public DbSet Tag { get; set; } public DbSet SiteTheme { get; set; } + public DbSet SeriesRelation { get; set; } protected override void OnModelCreating(ModelBuilder builder) @@ -59,10 +60,28 @@ namespace API.Data .WithOne(u => u.Role) .HasForeignKey(ur => ur.RoleId) .IsRequired(); + + builder.Entity() + .HasOne(pt => pt.Series) + .WithMany(p => p.Relations) + .HasForeignKey(pt => pt.SeriesId) + .OnDelete(DeleteBehavior.ClientCascade); + + builder.Entity() + .HasOne(pt => pt.TargetSeries) + .WithMany(t => t.RelationOf) + .HasForeignKey(pt => pt.TargetSeriesId); + + builder.Entity() + .Property(b => b.BookThemeName) + .HasDefaultValue("Dark"); + builder.Entity() + .Property(b => b.BackgroundColor) + .HasDefaultValue("#000000"); } - void OnEntityTracked(object sender, EntityTrackedEventArgs e) + static void OnEntityTracked(object sender, EntityTrackedEventArgs e) { if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity) { @@ -72,7 +91,7 @@ namespace API.Data } - void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e) + static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e) { if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity) entity.LastModified = DateTime.Now; diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index 46ebfbf10..ad97958da 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -35,7 +35,7 @@ namespace API.Data return new Volume() { Name = volumeNumber, - Number = (int) Parser.Parser.MinimumNumberFromRange(volumeNumber), + Number = (int) Parser.Parser.MinNumberFromRange(volumeNumber), Chapters = new List() }; } @@ -46,7 +46,7 @@ namespace API.Data var specialTitle = specialTreatment ? info.Filename : info.Chapters; return new Chapter() { - Number = specialTreatment ? "0" : Parser.Parser.MinimumNumberFromRange(info.Chapters) + string.Empty, + Number = specialTreatment ? "0" : Parser.Parser.MinNumberFromRange(info.Chapters) + string.Empty, Range = specialTreatment ? info.Filename : info.Chapters, Title = (specialTreatment && info.Format == MangaFormat.Epub) ? info.Title @@ -82,6 +82,29 @@ namespace API.Data }; } + public static ReadingList ReadingList(string title, string summary, bool promoted) + { + return new ReadingList() + { + NormalizedTitle = API.Parser.Parser.Normalize(title?.Trim()).ToUpper(), + Title = title?.Trim(), + Summary = summary?.Trim(), + Promoted = promoted, + Items = new List() + }; + } + + public static ReadingListItem ReadingListItem(int index, int seriesId, int volumeId, int chapterId) + { + return new ReadingListItem() + { + Order = index, + ChapterId = chapterId, + SeriesId = seriesId, + VolumeId = volumeId + }; + } + public static Genre Genre(string name, bool external) { return new Genre() diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index 040d7f6b7..0c236fd58 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -14,6 +14,10 @@ namespace API.Data.Metadata public string Summary { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Series { get; set; } = string.Empty; + /// + /// Localized Series name. Not standard. + /// + public string LocalizedSeries { get; set; } = string.Empty; public string SeriesSort { get; set; } = string.Empty; public string Number { get; set; } = string.Empty; /// @@ -47,11 +51,11 @@ namespace API.Data.Metadata /// public float UserRating { get; set; } - public string AlternateSeries { get; set; } = string.Empty; public string StoryArc { get; set; } = string.Empty; public string SeriesGroup { get; set; } = string.Empty; - public string AlternativeSeries { get; set; } = string.Empty; - public string AlternativeNumber { get; set; } = string.Empty; + public string AlternateNumber { get; set; } = string.Empty; + public int AlternateCount { get; set; } = 0; + public string AlternateSeries { get; set; } = string.Empty; /// /// This is Epub only: calibre:title_sort @@ -94,6 +98,10 @@ namespace API.Data.Metadata { if (info == null) return; + info.Series = info.Series.Trim(); + info.SeriesSort = info.SeriesSort.Trim(); + info.LocalizedSeries = info.LocalizedSeries.Trim(); + info.Writer = Parser.Parser.CleanAuthor(info.Writer); info.Colorist = Parser.Parser.CleanAuthor(info.Colorist); info.Editor = Parser.Parser.CleanAuthor(info.Editor); diff --git a/API/Data/MigrateRemoveExtraThemes.cs b/API/Data/MigrateRemoveExtraThemes.cs new file mode 100644 index 000000000..1c9a1e9b0 --- /dev/null +++ b/API/Data/MigrateRemoveExtraThemes.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Services.Tasks; + +namespace API.Data; + +/// +/// In v0.5.3, we removed Light and E-Ink themes. This migration will remove the themes from the DB and default anyone on +/// null, E-Ink, or Light to Dark. +/// +public static class MigrateRemoveExtraThemes +{ + public static async Task Migrate(IUnitOfWork unitOfWork, IThemeService themeService) + { + Console.WriteLine("Removing Dark and E-Ink themes"); + + var themes = (await unitOfWork.SiteThemeRepository.GetThemes()).ToList(); + + if (themes.FirstOrDefault(t => t.Name.Equals("Light")) == null) + { + Console.WriteLine("Done. Nothing to do"); + return; + } + + var darkTheme = themes.Single(t => t.Name.Equals("Dark")); + var lightTheme = themes.Single(t => t.Name.Equals("Light")); + var eInkTheme = themes.Single(t => t.Name.Equals("E-Ink")); + + + + // Update default theme if it's not Dark or a custom theme + await themeService.UpdateDefault(darkTheme.Id); + + // Update all users to Dark theme if they are on Light/E-Ink + foreach (var pref in await unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(lightTheme.Id)) + { + pref.Theme = darkTheme; + } + foreach (var pref in await unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(eInkTheme.Id)) + { + pref.Theme = darkTheme; + } + + // Remove Light/E-Ink themes + foreach (var siteTheme in themes.Where(t => t.Name.Equals("Light") || t.Name.Equals("E-Ink"))) + { + unitOfWork.SiteThemeRepository.Remove(siteTheme); + } + // Commit and call it a day + await unitOfWork.CommitAsync(); + + Console.WriteLine("Completed removing Dark and E-Ink themes"); + } + +} diff --git a/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.Designer.cs b/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.Designer.cs new file mode 100644 index 000000000..27d16bfde --- /dev/null +++ b/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.Designer.cs @@ -0,0 +1,1469 @@ +// +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("20220410230540_SeriesLastChapterAddedAndReadingListNormalization")] + partial class SeriesLastChapterAddedAndReadingListNormalization + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .HasColumnType("TEXT"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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.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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("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("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("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.cs b/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.cs new file mode 100644 index 000000000..445895472 --- /dev/null +++ b/API/Data/Migrations/20220410230540_SeriesLastChapterAddedAndReadingListNormalization.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class SeriesLastChapterAddedAndReadingListNormalization : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastChapterAdded", + table: "Series", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CoverImage", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CoverImageLocked", + table: "ReadingList", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "NormalizedTitle", + table: "ReadingList", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastChapterAdded", + table: "Series"); + + migrationBuilder.DropColumn( + name: "CoverImage", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "CoverImageLocked", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "NormalizedTitle", + table: "ReadingList"); + } + } +} diff --git a/API/Data/Migrations/20220416211340_RemoveCustomIndex.Designer.cs b/API/Data/Migrations/20220416211340_RemoveCustomIndex.Designer.cs new file mode 100644 index 000000000..dd2c6ce88 --- /dev/null +++ b/API/Data/Migrations/20220416211340_RemoveCustomIndex.Designer.cs @@ -0,0 +1,1466 @@ +// +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("20220416211340_RemoveCustomIndex")] + partial class RemoveCustomIndex + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .HasColumnType("TEXT"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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.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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("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("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("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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.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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220416211340_RemoveCustomIndex.cs b/API/Data/Migrations/20220416211340_RemoveCustomIndex.cs new file mode 100644 index 000000000..eb60f2349 --- /dev/null +++ b/API/Data/Migrations/20220416211340_RemoveCustomIndex.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class RemoveCustomIndex : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Series_Name_NormalizedName_LocalizedName_LibraryId_Format", + table: "Series"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Series_Name_NormalizedName_LocalizedName_LibraryId_Format", + table: "Series", + columns: new[] { "Name", "NormalizedName", "LocalizedName", "LibraryId", "Format" }, + unique: true); + } + } +} diff --git a/API/Data/Migrations/20220421214448_SeriesRelations.Designer.cs b/API/Data/Migrations/20220421214448_SeriesRelations.Designer.cs new file mode 100644 index 000000000..11937eb15 --- /dev/null +++ b/API/Data/Migrations/20220421214448_SeriesRelations.Designer.cs @@ -0,0 +1,1513 @@ +// +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("20220421214448_SeriesRelations")] + partial class SeriesRelations + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .HasColumnType("TEXT"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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.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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("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("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("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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.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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220421214448_SeriesRelations.cs b/API/Data/Migrations/20220421214448_SeriesRelations.cs new file mode 100644 index 000000000..1f6d5d7ab --- /dev/null +++ b/API/Data/Migrations/20220421214448_SeriesRelations.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class SeriesRelations : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "SeriesRelation", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RelationKind = table.Column(type: "INTEGER", nullable: false), + TargetSeriesId = table.Column(type: "INTEGER", nullable: false), + SeriesId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SeriesRelation", x => x.Id); + table.ForeignKey( + name: "FK_SeriesRelation_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SeriesRelation_Series_TargetSeriesId", + column: x => x.TargetSeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_SeriesRelation_SeriesId", + table: "SeriesRelation", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesRelation_TargetSeriesId", + table: "SeriesRelation", + column: "TargetSeriesId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SeriesRelation"); + } + } +} diff --git a/API/Data/Migrations/20220425125505_ChangeCountToTotalCount.Designer.cs b/API/Data/Migrations/20220425125505_ChangeCountToTotalCount.Designer.cs new file mode 100644 index 000000000..321cf7056 --- /dev/null +++ b/API/Data/Migrations/20220425125505_ChangeCountToTotalCount.Designer.cs @@ -0,0 +1,1513 @@ +// +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("20220425125505_ChangeCountToTotalCount")] + partial class ChangeCountToTotalCount + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .HasColumnType("TEXT"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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.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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("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("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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.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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220425125505_ChangeCountToTotalCount.cs b/API/Data/Migrations/20220425125505_ChangeCountToTotalCount.cs new file mode 100644 index 000000000..469430bc7 --- /dev/null +++ b/API/Data/Migrations/20220425125505_ChangeCountToTotalCount.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class ChangeCountToTotalCount : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_SeriesRelation_Series_SeriesId", + table: "SeriesRelation"); + + migrationBuilder.RenameColumn( + name: "Count", + table: "SeriesMetadata", + newName: "TotalCount"); + + migrationBuilder.AddForeignKey( + name: "FK_SeriesRelation_Series_SeriesId", + table: "SeriesRelation", + column: "SeriesId", + principalTable: "Series", + principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_SeriesRelation_Series_SeriesId", + table: "SeriesRelation"); + + migrationBuilder.RenameColumn( + name: "TotalCount", + table: "SeriesMetadata", + newName: "Count"); + + migrationBuilder.AddForeignKey( + name: "FK_SeriesRelation_Series_SeriesId", + table: "SeriesRelation", + column: "SeriesId", + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/API/Data/Migrations/20220425131122_AddMaxCountToSeriesMetadata.Designer.cs b/API/Data/Migrations/20220425131122_AddMaxCountToSeriesMetadata.Designer.cs new file mode 100644 index 000000000..0580b7497 --- /dev/null +++ b/API/Data/Migrations/20220425131122_AddMaxCountToSeriesMetadata.Designer.cs @@ -0,0 +1,1516 @@ +// +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("20220425131122_AddMaxCountToSeriesMetadata")] + partial class AddMaxCountToSeriesMetadata + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .HasColumnType("TEXT"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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.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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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.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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220425131122_AddMaxCountToSeriesMetadata.cs b/API/Data/Migrations/20220425131122_AddMaxCountToSeriesMetadata.cs new file mode 100644 index 000000000..550dac20b --- /dev/null +++ b/API/Data/Migrations/20220425131122_AddMaxCountToSeriesMetadata.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class AddMaxCountToSeriesMetadata : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MaxCount", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxCount", + table: "SeriesMetadata"); + } + } +} diff --git a/API/Data/Migrations/20220508162841_BookReaderUpdate.Designer.cs b/API/Data/Migrations/20220508162841_BookReaderUpdate.Designer.cs new file mode 100644 index 000000000..b8e7c6082 --- /dev/null +++ b/API/Data/Migrations/20220508162841_BookReaderUpdate.Designer.cs @@ -0,0 +1,1523 @@ +// +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("20220508162841_BookReaderUpdate")] + partial class BookReaderUpdate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.4"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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.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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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.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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220508162841_BookReaderUpdate.cs b/API/Data/Migrations/20220508162841_BookReaderUpdate.cs new file mode 100644 index 000000000..6df40e5fd --- /dev/null +++ b/API/Data/Migrations/20220508162841_BookReaderUpdate.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class BookReaderUpdate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "BookReaderDarkMode", + table: "AppUserPreferences", + newName: "PageLayoutMode"); + + migrationBuilder.AlterColumn( + name: "BackgroundColor", + table: "AppUserPreferences", + type: "TEXT", + nullable: true, + defaultValue: "#000000", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "BookThemeName", + table: "AppUserPreferences", + type: "TEXT", + nullable: true, + defaultValue: "Dark"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BookThemeName", + table: "AppUserPreferences"); + + migrationBuilder.RenameColumn( + name: "PageLayoutMode", + table: "AppUserPreferences", + newName: "BookReaderDarkMode"); + + migrationBuilder.AlterColumn( + name: "BackgroundColor", + table: "AppUserPreferences", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true, + oldDefaultValue: "#000000"); + } + } +} diff --git a/API/Data/Migrations/20220513234708_BookReaderImmersiveMode.Designer.cs b/API/Data/Migrations/20220513234708_BookReaderImmersiveMode.Designer.cs new file mode 100644 index 000000000..26c9a1397 --- /dev/null +++ b/API/Data/Migrations/20220513234708_BookReaderImmersiveMode.Designer.cs @@ -0,0 +1,1526 @@ +// +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("20220513234708_BookReaderImmersiveMode")] + partial class BookReaderImmersiveMode + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.4"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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.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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + 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.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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220513234708_BookReaderImmersiveMode.cs b/API/Data/Migrations/20220513234708_BookReaderImmersiveMode.cs new file mode 100644 index 000000000..f194a3b87 --- /dev/null +++ b/API/Data/Migrations/20220513234708_BookReaderImmersiveMode.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class BookReaderImmersiveMode : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BookReaderImmersiveMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BookReaderImmersiveMode", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index d46e91af6..a8b5527d5 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.2"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.4"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -166,10 +166,9 @@ namespace API.Data.Migrations .HasColumnType("INTEGER"); b.Property("BackgroundColor") - .HasColumnType("TEXT"); - - b.Property("BookReaderDarkMode") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); b.Property("BookReaderFontFamily") .HasColumnType("TEXT"); @@ -177,6 +176,9 @@ namespace API.Data.Migrations b.Property("BookReaderFontSize") .HasColumnType("INTEGER"); + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + b.Property("BookReaderLineSpacing") .HasColumnType("INTEGER"); @@ -189,9 +191,17 @@ namespace API.Data.Migrations b.Property("BookReaderTapToPaginate") .HasColumnType("INTEGER"); + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + b.Property("LayoutMode") .HasColumnType("INTEGER"); + b.Property("PageLayoutMode") + .HasColumnType("INTEGER"); + b.Property("PageSplitOption") .HasColumnType("INTEGER"); @@ -520,9 +530,6 @@ namespace API.Data.Migrations b.Property("ColoristLocked") .HasColumnType("INTEGER"); - b.Property("Count") - .HasColumnType("INTEGER"); - b.Property("CoverArtistLocked") .HasColumnType("INTEGER"); @@ -544,6 +551,9 @@ namespace API.Data.Migrations b.Property("LettererLocked") .HasColumnType("INTEGER"); + b.Property("MaxCount") + .HasColumnType("INTEGER"); + b.Property("PencillerLocked") .HasColumnType("INTEGER"); @@ -575,6 +585,9 @@ namespace API.Data.Migrations b.Property("TagsLocked") .HasColumnType("INTEGER"); + b.Property("TotalCount") + .HasColumnType("INTEGER"); + b.Property("TranslatorLocked") .HasColumnType("INTEGER"); @@ -592,6 +605,30 @@ namespace API.Data.Migrations 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") @@ -621,12 +658,21 @@ namespace API.Data.Migrations b.Property("AppUserId") .HasColumnType("INTEGER"); + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + b.Property("Created") .HasColumnType("TEXT"); b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + b.Property("Promoted") .HasColumnType("INTEGER"); @@ -695,6 +741,9 @@ namespace API.Data.Migrations b.Property("Format") .HasColumnType("INTEGER"); + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + b.Property("LastModified") .HasColumnType("TEXT"); @@ -732,9 +781,6 @@ namespace API.Data.Migrations b.HasIndex("LibraryId"); - b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") - .IsUnique(); - b.ToTable("Series"); }); @@ -1173,6 +1219,25 @@ namespace API.Data.Migrations b.Navigation("Series"); }); + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .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") @@ -1442,6 +1507,10 @@ namespace API.Data.Migrations b.Navigation("Ratings"); + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + b.Navigation("Volumes"); }); diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index a519c774f..7fbf14dd5 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -84,7 +84,6 @@ public class CollectionTagRepository : ICollectionTagRepository public async Task> GetAllTagDtosAsync() { return await _context.CollectionTag - .Select(c => c) .OrderBy(c => c.NormalizedTitle) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 59c6ac5c2..0294d6224 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -26,6 +26,8 @@ public interface IReadingListRepository void BulkRemove(IEnumerable items); void Update(ReadingList list); Task Count(); + Task GetCoverImageAsync(int readingListId); + Task> GetAllCoverImagesAsync(); } public class ReadingListRepository : IReadingListRepository @@ -49,6 +51,24 @@ public class ReadingListRepository : IReadingListRepository return await _context.ReadingList.CountAsync(); } + public async Task GetCoverImageAsync(int readingListId) + { + return await _context.ReadingList + .Where(c => c.Id == readingListId) + .Select(c => c.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } + + public async Task> GetAllCoverImagesAsync() + { + return await _context.ReadingList + .Select(t => t.CoverImage) + .Where(t => !string.IsNullOrEmpty(t)) + .AsNoTracking() + .ToListAsync(); + } + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index bf634e5c7..c36bcc4cb 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -11,6 +11,7 @@ using API.DTOs.Filtering; using API.DTOs.Metadata; using API.DTOs.ReadingLists; using API.DTOs.Search; +using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -20,10 +21,23 @@ using API.Services.Tasks; using AutoMapper; using AutoMapper.QueryableExtensions; using Kavita.Common.Extensions; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SQLitePCL; namespace API.Data.Repositories; +[Flags] +public enum SeriesIncludes +{ + None = 1, + Volumes = 2, + Metadata = 4, + Related = 8, + //Related = 16, + //UserPreferences = 32 +} + internal class RecentlyAddedSeries { public int LibraryId { get; init; } @@ -68,7 +82,7 @@ public interface ISeriesRepository Task> GetSeriesForLibraryIdAsync(int libraryId); Task GetSeriesDtoByIdAsync(int seriesId, int userId); Task DeleteSeriesAsync(int seriesId); - Task GetSeriesByIdAsync(int seriesId); + Task GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata); Task> GetSeriesByIdsAsync(IList seriesIds); Task GetChapterIdsForSeriesAsync(IList seriesIds); Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds); @@ -80,7 +94,7 @@ public interface ISeriesRepository /// Task AddSeriesModifiers(int userId, List series); Task GetSeriesCoverImageAsync(int seriesId); - Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true); + Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter); Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); Task GetSeriesMetadata(int seriesId); Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); @@ -92,11 +106,18 @@ public interface ISeriesRepository Task GetFullSeriesForSeriesIdAsync(int seriesId); Task GetChunkInfo(int libraryId = 0); Task> GetSeriesMetadataForIdsAsync(IEnumerable seriesIds); - Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); - Task> GetAllLanguagesForLibrariesAsync(List libraryIds); - Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); - Task> GetRecentlyUpdatedSeries(int userId); - Task> GetRecentlyAddedChapters(int userId); + Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); // TODO: Move to LibraryRepository + Task> GetAllLanguagesForLibrariesAsync(List libraryIds); // TODO: Move to LibraryRepository + IEnumerable GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); // TODO: Move to LibraryRepository + Task> GetRecentlyUpdatedSeries(int userId, int pageSize = 30); + Task GetRelatedSeries(int userId, int seriesId); + Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind); + Task> GetQuickReads(int userId, int libraryId, UserParams userParams); + Task> GetHighlyRated(int userId, int libraryId, UserParams userParams); + Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams); + Task> GetRediscover(int userId, int libraryId, UserParams userParams); + Task GetSeriesForMangaFile(int mangaFileId, int userId); + Task GetSeriesForChapter(int chapterId, int userId); } public class SeriesRepository : ISeriesRepository @@ -232,7 +253,7 @@ public class SeriesRepository : ISeriesRepository /// /// Gets all series /// - /// + /// Restricts to just one library /// /// /// @@ -269,7 +290,7 @@ public class SeriesRepository : ISeriesRepository public async Task SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery) { - + const int maxRecords = 15; var result = new SearchResultGroupDto(); var searchQueryNormalized = Parser.Parser.Normalize(searchQuery); @@ -283,6 +304,7 @@ public class SeriesRepository : ISeriesRepository .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) .OrderBy(l => l.Name) .AsSplitQuery() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -290,7 +312,7 @@ public class SeriesRepository : ISeriesRepository var hasYearInQuery = !string.IsNullOrEmpty(justYear); var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0; - result.Series = await _context.Series + result.Series = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") || EF.Functions.Like(s.OriginalName, $"%{searchQuery}%") @@ -301,14 +323,16 @@ public class SeriesRepository : ISeriesRepository .OrderBy(s => s.SortName) .AsNoTracking() .AsSplitQuery() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); + .AsEnumerable(); result.ReadingLists = await _context.ReadingList .Where(rl => rl.AppUserId == userId || rl.Promoted) .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) .AsSplitQuery() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -318,6 +342,8 @@ public class SeriesRepository : ISeriesRepository .Where(s => s.Promoted || isAdmin) .OrderBy(s => s.Title) .AsNoTracking() + .AsSplitQuery() + .Take(maxRecords) .OrderBy(c => c.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -326,6 +352,7 @@ public class SeriesRepository : ISeriesRepository .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.People.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%"))) .AsSplitQuery() + .Take(maxRecords) .Distinct() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -336,6 +363,7 @@ public class SeriesRepository : ISeriesRepository .AsSplitQuery() .OrderBy(t => t.Title) .Distinct() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -345,9 +373,34 @@ public class SeriesRepository : ISeriesRepository .AsSplitQuery() .OrderBy(t => t.Title) .Distinct() + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); + var fileIds = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .AsSplitQuery() + .SelectMany(s => s.Volumes) + .SelectMany(v => v.Chapters) + .SelectMany(c => c.Files.Select(f => f.Id)); + + result.Files = await _context.MangaFile + .Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id)) + .AsSplitQuery() + .Take(maxRecords) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + + + result.Chapters = await _context.Chapter + .Include(c => c.Files) + .Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%")) + .Where(c => c.Files.All(f => fileIds.Contains(f.Id))) + .AsSplitQuery() + .Take(maxRecords) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + return result; } @@ -377,19 +430,37 @@ public class SeriesRepository : ISeriesRepository /// /// /// - public async Task GetSeriesByIdAsync(int seriesId) + public async Task GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata) { - return await _context.Series - .Include(s => s.Volumes) - .Include(s => s.Metadata) - .ThenInclude(m => m.CollectionTags) - .Include(s => s.Metadata) - .ThenInclude(m => m.Genres) - .Include(s => s.Metadata) - .ThenInclude(m => m.People) + var query = _context.Series .Where(s => s.Id == seriesId) - .AsSplitQuery() - .SingleOrDefaultAsync(); + .AsSplitQuery(); + + if (includes.HasFlag(SeriesIncludes.Volumes)) + { + query = query.Include(s => s.Volumes); + } + + if (includes.HasFlag(SeriesIncludes.Related)) + { + query = query.Include(s => s.Relations) + .ThenInclude(r => r.TargetSeries) + .Include(s => s.RelationOf); + } + + if (includes.HasFlag(SeriesIncludes.Metadata)) + { + query = query.Include(s => s.Metadata) + .ThenInclude(m => m.CollectionTags) + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres) + .Include(s => s.Metadata) + .ThenInclude(m => m.People) + .Include(s => s.Metadata) + .ThenInclude(m => m.Tags); + } + + return await query.SingleOrDefaultAsync(); } /// @@ -597,55 +668,48 @@ public class SeriesRepository : ISeriesRepository } /// - /// Returns Series that the user has some partial progress on. Sorts based on activity. Sort first by User progress, but if a series - /// has been updated recently, bump it to the front. + /// Returns Series that the user has some partial progress on. Sorts based on activity. Sort first by User progress, then + /// by when chapters have been added to series. Restricts progress in the past 30 days and chapters being added to last 7. /// /// /// Library to restrict to, if 0, will apply to all libraries /// Pagination information /// Optional (default null) filter on query /// - public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter, bool cutoffOnDate = true) + public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter) { var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); - var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter)) - .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => - new - { - Series = s, - PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId) - .Sum(s1 => s1.PagesRead), - progress.AppUserId, - LastReadingProgress = _context.AppUserProgresses - .Where(p => p.Id == progress.Id && p.AppUserId == userId) - .Max(p => p.LastModified), - // This is only taking into account chapters that have progress on them, not all chapters in said series - //LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created), - LastChapterCreated = s.Volumes.SelectMany(v => v.Chapters).Max(c => c.Created) - }); - if (cutoffOnDate) - { - query = query.Where(d => d.LastReadingProgress >= cutoffProgressPoint || d.LastChapterCreated >= cutoffProgressPoint); - } + var cutoffLastAddedPoint = DateTime.Now - TimeSpan.FromDays(7); - // I think I need another Join statement. The problem is the chapters are still limited to progress + var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); - - var retSeries = query.Where(s => s.AppUserId == userId - && s.PagesRead > 0 - && s.PagesRead < s.Series.Pages) - .OrderByDescending(s => s.LastReadingProgress) - .ThenByDescending(s => s.LastChapterCreated) + var query = _context.Series + .Where(s => usersSeriesIds.Contains(s.Id)) + .Select(s => new + { + Series = s, + PagesRead = _context.AppUserProgresses.Where(p => p.SeriesId == s.Id && p.AppUserId == userId) + .Sum(s1 => s1.PagesRead), + LatestReadDate = _context.AppUserProgresses + .Where(p => p.SeriesId == s.Id && p.AppUserId == userId) + .Max(p => p.LastModified), + s.LastChapterAdded, + }) + .Where(s => s.PagesRead > 0 + && s.PagesRead < s.Series.Pages) + .Where(d => d.LatestReadDate >= cutoffProgressPoint || d.LastChapterAdded >= cutoffLastAddedPoint).OrderByDescending(s => s.LatestReadDate) + .ThenByDescending(s => s.LastChapterAdded) .Select(s => s.Series) .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() .AsNoTracking(); - // Pagination does not work for this query as when we pull the data back, we get multiple rows of the same series. See controller for pagination code - return await retSeries.ToListAsync(); + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } + private async Task> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter) { var userLibraries = await GetUserLibraries(libraryId, userId); @@ -687,6 +751,7 @@ public class SeriesRepository : ISeriesRepository SortField.SortName => query.OrderBy(s => s.SortName), SortField.CreatedDate => query.OrderBy(s => s.Created), SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), + SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), _ => query }; } @@ -697,6 +762,7 @@ public class SeriesRepository : ISeriesRepository SortField.SortName => query.OrderByDescending(s => s.SortName), SortField.CreatedDate => query.OrderByDescending(s => s.Created), SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), + SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), _ => query }; } @@ -888,131 +954,258 @@ public class SeriesRepository : ISeriesRepository .ToList(); } - public async Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds) + public IEnumerable GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds) { - return await _context.Series + return _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Select(s => s.Metadata.PublicationStatus) .Distinct() + .AsEnumerable() .Select(s => new PublicationStatusDto() { Value = s, Title = s.ToDescription() }) - .OrderBy(s => s.Title) - .ToListAsync(); - } - - private static string RecentlyAddedItemTitle(RecentlyAddedSeries item) - { - switch (item.LibraryType) - { - case LibraryType.Book: - return string.Empty; - case LibraryType.Comic: - return "Issue"; - case LibraryType.Manga: - default: - return "Chapter"; - } - } - - /// - /// Show all recently added chapters. Provide some mapping for chapter 0 -> Volume 1 - /// - /// - /// - public async Task> GetRecentlyAddedChapters(int userId) - { - var ret = await GetRecentlyAddedChaptersQuery(userId); - - var items = new List(); - foreach (var item in ret) - { - var dto = new RecentlyAddedItemDto() - { - LibraryId = item.LibraryId, - LibraryType = item.LibraryType, - SeriesId = item.SeriesId, - SeriesName = item.SeriesName, - Created = item.Created, - Id = items.Count, - Format = item.Format - }; - - // Add title and Volume/Chapter Id - var chapterTitle = RecentlyAddedItemTitle(item); - string title; - if (item.ChapterNumber.Equals(Parser.Parser.DefaultChapter)) - { - if ((item.VolumeNumber + string.Empty).Equals(Parser.Parser.DefaultChapter)) - { - title = item.ChapterTitle; - } - else - { - title = "Volume " + item.VolumeNumber; - } - - dto.VolumeId = item.VolumeId; - } - else - { - title = item.IsSpecial - ? item.ChapterRange - : $"{chapterTitle} {item.ChapterRange}"; - dto.ChapterId = item.ChapterId; - } - - dto.Title = title; - items.Add(dto); - } - - - return items; - + .OrderBy(s => s.Title); } /// /// Return recently updated series, regardless of read progress, and group the number of volume or chapters added. /// + /// This provides 2 levels of pagination. Fetching the individual chapters only looks at 3000. Then when performing grouping + /// in memory, we stop after 30 series. /// Used to ensure user has access to libraries /// - public async Task> GetRecentlyUpdatedSeries(int userId) + public async Task> GetRecentlyUpdatedSeries(int userId, int pageSize = 30) { - var ret = await GetRecentlyAddedChaptersQuery(userId, 150); - - var seriesMap = new Dictionary(); - var index = 0; - foreach (var item in ret) - { - if (seriesMap.ContainsKey(item.SeriesName)) - { - seriesMap[item.SeriesName].Count += 1; - } - else - { - seriesMap[item.SeriesName] = new GroupedSeriesDto() - { - LibraryId = item.LibraryId, - LibraryType = item.LibraryType, - SeriesId = item.SeriesId, - SeriesName = item.SeriesName, - Created = item.Created, - Id = index, - Format = item.Format, - Count = 1 - }; - index += 1; - } - } + var index = 0; + foreach (var item in await GetRecentlyAddedChaptersQuery(userId)) + { + if (seriesMap.Keys.Count == pageSize) break; - return seriesMap.Values.ToList(); + if (seriesMap.ContainsKey(item.SeriesName)) + { + seriesMap[item.SeriesName].Count += 1; + } + else + { + seriesMap[item.SeriesName] = new GroupedSeriesDto() + { + LibraryId = item.LibraryId, + LibraryType = item.LibraryType, + SeriesId = item.SeriesId, + SeriesName = item.SeriesName, + Created = item.Created, + Id = index, + Format = item.Format, + Count = 1, + }; + index += 1; + } + } + + return seriesMap.Values.AsEnumerable(); } - private async Task> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 50) + public async Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind) + { + var libraryIds = GetLibraryIdsForUser(userId); + var usersSeriesIds = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Id); + + var targetSeries = _context.SeriesRelation + .Where(sr => + sr.SeriesId == seriesId && sr.RelationKind == kind && usersSeriesIds.Contains(sr.TargetSeriesId)) + .Include(sr => sr.TargetSeries) + .AsSplitQuery() + .AsNoTracking() + .Select(sr => sr.TargetSeriesId); + + return await _context.Series + .Where(s => targetSeries.Contains(s.Id)) + .AsSplitQuery() + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + private IQueryable GetLibraryIdsForUser(int userId) + { + return _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(l => l.Libraries.Select(lib => lib.Id)); + } + + public async Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) + { + var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + + var query = _context.Series + .Where(s => s.Metadata.Genres.Select(g => g.Id).Contains(genreId)) + .Where(s => usersSeriesIds.Contains(s.Id)) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider); + + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + public async Task> GetRediscover(int userId, int libraryId, UserParams userParams) + { + var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + var distinctSeriesIdsWithProgress = _context.AppUserProgresses + .Where(s => usersSeriesIds.Contains(s.SeriesId)) + .Select(p => p.SeriesId) + .Distinct(); + + var query = _context.Series + .Where(s => distinctSeriesIdsWithProgress.Contains(s.Id) && + _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId) + .Sum(s1 => s1.PagesRead) >= s.Pages) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + public async Task GetSeriesForMangaFile(int mangaFileId, int userId) + { + var libraryIds = GetLibraryIdsForUser(userId); + return await _context.MangaFile + .Where(m => m.Id == mangaFileId) + .AsSplitQuery() + .Select(f => f.Chapter) + .Select(c => c.Volume) + .Select(v => v.Series) + .Where(s => libraryIds.Contains(s.LibraryId)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + + public async Task GetSeriesForChapter(int chapterId, int userId) + { + var libraryIds = GetLibraryIdsForUser(userId); + return await _context.Chapter + .Where(m => m.Id == chapterId) + .AsSplitQuery() + .Select(c => c.Volume) + .Select(v => v.Series) + .Where(s => libraryIds.Contains(s.LibraryId)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + + + public async Task> GetHighlyRated(int userId, int libraryId, UserParams userParams) + { + var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + var distinctSeriesIdsWithHighRating = _context.AppUserRating + .Where(s => usersSeriesIds.Contains(s.SeriesId) && s.Rating > 4) + .Select(p => p.SeriesId) + .Distinct(); + + var query = _context.Series + .Where(s => distinctSeriesIdsWithHighRating.Contains(s.Id)) + .AsSplitQuery() + .OrderByDescending(s => _context.AppUserRating.Where(r => r.SeriesId == s.Id).Select(r => r.Rating).Average()) + .ProjectTo(_mapper.ConfigurationProvider); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + + public async Task> GetQuickReads(int userId, int libraryId, UserParams userParams) + { + var libraryIds = GetLibraryIdsForUser(userId, libraryId); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + var distinctSeriesIdsWithProgress = _context.AppUserProgresses + .Where(s => usersSeriesIds.Contains(s.SeriesId)) + .Select(p => p.SeriesId) + .Distinct(); + + + var query = _context.Series + .Where(s => s.Pages < 2000 && !distinctSeriesIdsWithProgress.Contains(s.Id) && + usersSeriesIds.Contains(s.Id)) + .Where(s => s.Metadata.PublicationStatus != PublicationStatus.OnGoing) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider); + + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + /// + /// Returns all library ids for a user + /// + /// + /// 0 for no library filter + /// + private IQueryable GetLibraryIdsForUser(int userId, int libraryId) + { + return _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(l => l.Libraries.Where(l => l.Id == libraryId || libraryId == 0).Select(lib => lib.Id)); + } + + public async Task GetRelatedSeries(int userId, int seriesId) + { + var libraryIds = GetLibraryIdsForUser(userId); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + + return new RelatedSeriesDto() + { + SourceSeriesId = seriesId, + Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation), + Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character), + Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel), + Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel), + Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains), + SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory), + SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff), + Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other), + AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting), + AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion), + Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi), + Parent = await _context.Series + .SelectMany(s => + s.RelationOf.Where(r => r.TargetSeriesId == seriesId + && usersSeriesIds.Contains(r.TargetSeriesId) + && r.RelationKind != RelationKind.Prequel + && r.RelationKind != RelationKind.Sequel) + .Select(sr => sr.Series)) + .AsSplitQuery() + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync() + }; + } + + private IQueryable GetSeriesIdsForLibraryIds(IQueryable libraryIds) + { + return _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Id); + } + + private async Task> GetRelatedSeriesQuery(int seriesId, IEnumerable usersSeriesIds, RelationKind kind) + { + return await _context.Series.SelectMany(s => + s.Relations.Where(sr => sr.RelationKind == kind && sr.SeriesId == seriesId && usersSeriesIds.Contains(sr.TargetSeriesId)) + .Select(sr => sr.TargetSeries)) + .AsSplitQuery() + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + private async Task> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 3000) { var libraries = await _context.AppUser .Where(u => u.Id == userId) @@ -1021,7 +1214,7 @@ public class SeriesRepository : ISeriesRepository var libraryIds = libraries.Select(l => l.LibraryId).ToList(); var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12); - var ret = await _context.Chapter + var ret = _context.Chapter .Where(c => c.Created >= withinLastWeek) .AsNoTracking() .Include(c => c.Volume) @@ -1044,9 +1237,10 @@ public class SeriesRepository : ISeriesRepository VolumeNumber = c.Volume.Number, ChapterTitle = c.Title }) - .Take(maxRecords) + //.Take(maxRecords) + .AsSplitQuery() .Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId)) - .ToListAsync(); + .AsEnumerable(); return ret; } } diff --git a/API/Data/Repositories/SiteThemeRepository.cs b/API/Data/Repositories/SiteThemeRepository.cs index a95fcda23..98f9c8c87 100644 --- a/API/Data/Repositories/SiteThemeRepository.cs +++ b/API/Data/Repositories/SiteThemeRepository.cs @@ -19,7 +19,6 @@ public interface ISiteThemeRepository Task GetThemeDtoByName(string themeName); Task GetDefaultTheme(); Task> GetThemes(); - Task GetThemeById(int themeId); } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index ec0088aba..4ecd27da5 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -21,35 +21,20 @@ namespace API.Data /// /// Generated on Startup. Seed.SeedSettings must run before /// - public static IList DefaultSettings; + public static ImmutableArray DefaultSettings; - public static readonly IList DefaultThemes = new List - { - new() + public static readonly ImmutableArray DefaultThemes = ImmutableArray.Create( + new List { - Name = "Dark", - NormalizedName = Parser.Parser.Normalize("Dark"), - Provider = ThemeProvider.System, - FileName = "dark.scss", - IsDefault = true, - }, - new() - { - Name = "Light", - NormalizedName = Parser.Parser.Normalize("Light"), - Provider = ThemeProvider.System, - FileName = "light.scss", - IsDefault = false, - }, - new() - { - Name = "E-Ink", - NormalizedName = Parser.Parser.Normalize("E-Ink"), - Provider = ThemeProvider.System, - FileName = "e-ink.scss", - IsDefault = false, - }, - }; + new() + { + Name = "Dark", + NormalizedName = Parser.Parser.Normalize("Dark"), + Provider = ThemeProvider.System, + FileName = "dark.scss", + IsDefault = true, + } + }.ToArray()); public static async Task SeedRoles(RoleManager roleManager) { @@ -90,24 +75,32 @@ namespace API.Data public static async Task SeedSettings(DataContext context, IDirectoryService directoryService) { await context.Database.EnsureCreatedAsync(); - - DefaultSettings = new List() + DefaultSettings = ImmutableArray.Create(new List() { - new () {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory}, - new () {Key = ServerSettingKey.TaskScan, Value = "daily"}, - new () {Key = ServerSettingKey.LoggingLevel, Value = "Information"}, // Not used from DB, but DB is sync with appSettings.json - new () {Key = ServerSettingKey.TaskBackup, Value = "daily"}, - new () {Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory)}, - new () {Key = ServerSettingKey.Port, Value = "5000"}, // Not used from DB, but DB is sync with appSettings.json - new () {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, - new () {Key = ServerSettingKey.EnableOpds, Value = "false"}, - new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, - new () {Key = ServerSettingKey.BaseUrl, Value = "/"}, - new () {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, - new () {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, - new () {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, - new () {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, - }; + new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory}, + new() {Key = ServerSettingKey.TaskScan, Value = "daily"}, + new() + { + Key = ServerSettingKey.LoggingLevel, Value = "Information" + }, // Not used from DB, but DB is sync with appSettings.json + new() {Key = ServerSettingKey.TaskBackup, Value = "daily"}, + new() + { + Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory) + }, + new() + { + Key = ServerSettingKey.Port, Value = "5000" + }, // Not used from DB, but DB is sync with appSettings.json + new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, + new() {Key = ServerSettingKey.EnableOpds, Value = "false"}, + new() {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, + new() {Key = ServerSettingKey.BaseUrl, Value = "/"}, + new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, + new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, + new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, + new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, + }.ToArray()); foreach (var defaultSetting in DefaultSettings) { diff --git a/API/Dockerfile b/API/Dockerfile deleted file mode 100644 index ce607a02f..000000000 --- a/API/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -#This Dockerfile pulls the latest git commit and builds Kavita from source -FROM mcr.microsoft.com/dotnet/sdk:6.0-focal AS builder - -ENV DEBIAN_FRONTEND=noninteractive -ARG TARGETPLATFORM - -#Installs nodejs and npm -RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - \ - && apt-get install -y nodejs \ - && rm -rf /var/lib/apt/lists/* - -#Builds app based on platform -COPY build_target.sh /build_target.sh -RUN /build_target.sh - -#Production image -FROM ubuntu:focal - -#Move the output files to where they need to be -COPY --from=builder /Projects/Kavita/_output/build/Kavita /kavita - -#Installs program dependencies -RUN apt-get update \ - && apt-get install -y libicu-dev libssl1.1 pwgen \ - && rm -rf /var/lib/apt/lists/* - -#Creates the manga storage directory -RUN mkdir /manga /kavita/data - -RUN cp /kavita/appsettings.Development.json /kavita/appsettings.json \ - && sed -i 's/Data source=kavita.db/Data source=data\/kavita.db/g' /kavita/appsettings.json - -COPY entrypoint.sh /entrypoint.sh - -EXPOSE 5000 - -WORKDIR /kavita - -ENTRYPOINT ["/bin/bash"] -CMD ["/entrypoint.sh"] diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index d35b82e39..bd68bc5ef 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -25,6 +25,7 @@ namespace API.Entities /// /// public ReaderMode ReaderMode { get; set; } + /// /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction /// @@ -42,10 +43,6 @@ namespace API.Entities /// public string BackgroundColor { get; set; } = "#000000"; /// - /// Book Reader Option: Should the background color be dark - /// - public bool BookReaderDarkMode { get; set; } = true; - /// /// Book Reader Option: Override extra Margin /// public int BookReaderMargin { get; set; } = 15; @@ -74,7 +71,22 @@ namespace API.Entities /// /// Should default to Dark public SiteTheme Theme { get; set; } - + /// + /// Book Reader Option: The color theme to decorate the book contents + /// + /// Should default to Dark + public string BookThemeName { get; set; } = "Dark"; + /// + /// Book Reader Option: The way a page from a book is rendered. Default is as book dictates, 1 column is fit to height, + /// 2 column is fit to height, 2 columns + /// + /// Defaults to Default + public BookPageLayoutMode PageLayoutMode { get; set; } = BookPageLayoutMode.Default; + /// + /// 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. + /// + /// Defaults to false + public bool BookReaderImmersiveMode { get; set; } = false; public AppUser AppUser { get; set; } diff --git a/API/Entities/Enums/AgeRating.cs b/API/Entities/Enums/AgeRating.cs index ed9deac25..82dbef7ae 100644 --- a/API/Entities/Enums/AgeRating.cs +++ b/API/Entities/Enums/AgeRating.cs @@ -27,7 +27,7 @@ public enum AgeRating KidsToAdults = 7, [Description("Teen")] Teen = 8, - [Description("MA 15+")] + [Description("MA15+")] Mature15Plus = 9, [Description("Mature 17+")] Mature17Plus = 10, diff --git a/API/Entities/Enums/BookPageLayoutMode.cs b/API/Entities/Enums/BookPageLayoutMode.cs new file mode 100644 index 000000000..dc61b5a1e --- /dev/null +++ b/API/Entities/Enums/BookPageLayoutMode.cs @@ -0,0 +1,13 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +public enum BookPageLayoutMode +{ + [Description("Default")] + Default = 0, + [Description("1 Column")] + Column1 = 1, + [Description("2 Column")] + Column2 = 2 +} diff --git a/API/Entities/Enums/PublicationStatus.cs b/API/Entities/Enums/PublicationStatus.cs index 69f700fc6..614bc0604 100644 --- a/API/Entities/Enums/PublicationStatus.cs +++ b/API/Entities/Enums/PublicationStatus.cs @@ -18,6 +18,16 @@ public enum PublicationStatus /// Publication has finished releasing /// [Description("Completed")] - Completed = 2 + Completed = 2, + /// + /// Publication has been cancelled + /// + [Description("Cancelled")] + Cancelled = 3, + /// + /// Publication has been completed, but Kavita doesn't have all issues/volumes accounted for + /// + [Description("Ended")] + Ended = 4 } diff --git a/API/Entities/Enums/RelationKind.cs b/API/Entities/Enums/RelationKind.cs new file mode 100644 index 000000000..c01ab9571 --- /dev/null +++ b/API/Entities/Enums/RelationKind.cs @@ -0,0 +1,66 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +/// +/// Represents a relationship between Series +/// +public enum RelationKind +{ + /// + /// Story that occurred before the original. + /// + [Description("Prequel")] + Prequel = 1, + /// + /// Direct continuation of the story. + /// + [Description("Sequel")] + Sequel = 2, + /// + /// Uses characters of a different series, but is not an alternate setting or story. + /// + [Description("Spin Off")] + SpinOff = 3, + /// + /// Manga/Anime/Light Novel adaptation + /// + [Description("Adaptation")] + Adaptation = 4, + /// + /// Takes place sometime during the parent storyline. + /// + [Description("Side Story")] + SideStory = 5, + /// + /// When characters appear in both series, but is not a spin-off + /// + [Description("Character")] + Character = 6, + /// + /// When the story contains another story, useful for One-Shots + /// + [Description("Contains")] + Contains = 7, + /// + /// When nothing else fits + /// + [Description("Other")] + Other = 8, + /// + /// Same universe/world/reality/timeline, completely different characters + /// + [Description("Alternative Setting")] + AlternativeSetting = 9, + /// + /// Same setting, same characters, story is told differently + /// + [Description("Alternative Version")] + AlternativeVersion = 10, + /// + /// Doujinshi or Fan work + /// + [Description("Doujinshi")] + Doujinshi = 11 + +} diff --git a/API/Entities/Interfaces/ITheme.cs b/API/Entities/Interfaces/ITheme.cs new file mode 100644 index 000000000..216136569 --- /dev/null +++ b/API/Entities/Interfaces/ITheme.cs @@ -0,0 +1,15 @@ +using API.Entities.Enums.Theme; + +namespace API.Entities.Interfaces; + +/// +/// A theme in some kind +/// +public interface ITheme +{ + public string Name { get; set; } + public string NormalizedName { get; set; } + public string FileName { get; set; } + public bool IsDefault { get; set; } + public ThemeProvider Provider { get; set; } +} diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 522f15d06..98e9fa8e9 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -35,9 +35,13 @@ namespace API.Entities.Metadata /// public string Language { get; set; } = string.Empty; /// - /// Total number of issues in the series + /// Total number of issues/volumes in the series /// - public int Count { get; set; } = 0; + public int TotalCount { get; set; } = 0; + /// + /// Max number of issues/volumes in the series (Max of Volume/Issue field in ComicInfo) + /// + public int MaxCount { get; set; } = 0; public PublicationStatus PublicationStatus { get; set; } // Locks diff --git a/API/Entities/Metadata/SeriesRelation.cs b/API/Entities/Metadata/SeriesRelation.cs new file mode 100644 index 000000000..46e6c34b9 --- /dev/null +++ b/API/Entities/Metadata/SeriesRelation.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using API.Entities.Enums; + +namespace API.Entities.Metadata; + +/// +/// A relation flows between one series and another. +/// Series ---kind---> target +/// +public class SeriesRelation +{ + public int Id { get; set; } + public RelationKind RelationKind { get; set; } + + public virtual Series TargetSeries { get; set; } + /// + /// A is Sequel to B. In this example, TargetSeries is A. B will hold the foreign key. + /// + public int TargetSeriesId { get; set; } + + // Relationships + public virtual Series Series { get; set; } + public int SeriesId { get; set; } +} diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs index ef0b4bd9c..b665203c4 100644 --- a/API/Entities/ReadingList.cs +++ b/API/Entities/ReadingList.cs @@ -11,11 +11,21 @@ namespace API.Entities { public int Id { get; init; } public string Title { get; set; } + /// + /// A normalized string used to check if the reading list already exists in the DB + /// + public string NormalizedTitle { get; set; } public string Summary { get; set; } /// /// Reading lists that are promoted are only done by admins /// public bool Promoted { get; set; } + /// + /// Absolute path to the (managed) image file + /// + /// The file is managed internally to Kavita's APPDIR + public string CoverImage { get; set; } + public bool CoverImageLocked { get; set; } public ICollection Items { get; set; } public DateTime Created { get; set; } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 12e169c07..1ddd8f082 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -3,73 +3,84 @@ using System.Collections.Generic; using API.Entities.Enums; using API.Entities.Interfaces; using API.Entities.Metadata; -using Microsoft.EntityFrameworkCore; -namespace API.Entities +namespace API.Entities; + +public class Series : IEntityDate { - [Index(nameof(Name), nameof(NormalizedName), nameof(LocalizedName), nameof(LibraryId), nameof(Format), IsUnique = true)] - public class Series : IEntityDate - { - public int Id { get; set; } - /// - /// The UI visible Name of the Series. This may or may not be the same as the OriginalName - /// - public string Name { get; set; } - /// - /// Used internally for name matching. - /// - public string NormalizedName { get; set; } - /// - /// The name used to sort the Series. By default, will be the same as Name. - /// - public string SortName { get; set; } - /// - /// Name in original language (Japanese for Manga). By default, will be same as Name. - /// - public string LocalizedName { get; set; } - /// - /// Original Name on disk. Not exposed to UI. - /// - public string OriginalName { get; set; } - /// - /// Time of creation - /// - public DateTime Created { get; set; } - /// - /// Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc - /// - public DateTime LastModified { get; set; } - /// - /// Absolute path to the (managed) image file - /// - /// The file is managed internally to Kavita's APPDIR - public string CoverImage { get; set; } - /// - /// Denotes if the CoverImage has been overridden by the user. If so, it will not be updated during normal scan operations. - /// - public bool CoverImageLocked { get; set; } - /// - /// Sum of all Volume page counts - /// - public int Pages { get; set; } + public int Id { get; set; } + /// + /// The UI visible Name of the Series. This may or may not be the same as the OriginalName + /// + public string Name { get; set; } + /// + /// Used internally for name matching. + /// + public string NormalizedName { get; set; } + /// + /// The name used to sort the Series. By default, will be the same as Name. + /// + public string SortName { get; set; } + /// + /// Name in original language (Japanese for Manga). By default, will be same as Name. + /// + public string LocalizedName { get; set; } + /// + /// Original Name on disk. Not exposed to UI. + /// + public string OriginalName { get; set; } + /// + /// Time of creation + /// + public DateTime Created { get; set; } + /// + /// Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc + /// + public DateTime LastModified { get; set; } + /// + /// Absolute path to the (managed) image file + /// + /// The file is managed internally to Kavita's APPDIR + public string CoverImage { get; set; } + /// + /// Denotes if the CoverImage has been overridden by the user. If so, it will not be updated during normal scan operations. + /// + public bool CoverImageLocked { get; set; } + /// + /// Sum of all Volume page counts + /// + public int Pages { get; set; } - /// - /// The type of all the files attached to this series - /// - public MangaFormat Format { get; set; } = MangaFormat.Unknown; + /// + /// The type of all the files attached to this series + /// + public MangaFormat Format { get; set; } = MangaFormat.Unknown; - public bool NameLocked { get; set; } - public bool SortNameLocked { get; set; } - public bool LocalizedNameLocked { get; set; } + public bool NameLocked { get; set; } + public bool SortNameLocked { get; set; } + public bool LocalizedNameLocked { get; set; } - public SeriesMetadata Metadata { get; set; } - public ICollection Ratings { get; set; } = new List(); - public ICollection Progress { get; set; } = new List(); + /// + /// When a Chapter was last added onto the Series + /// + public DateTime LastChapterAdded { get; set; } - // Relationships - public List Volumes { get; set; } - public Library Library { get; set; } - public int LibraryId { get; set; } + public SeriesMetadata Metadata { get; set; } + + public ICollection Ratings { get; set; } = new List(); + public ICollection Progress { get; set; } = new List(); + + /// + /// Relations to other Series, like Sequels, Prequels, etc + /// + /// 1 to Many relationship + public virtual ICollection Relations { get; set; } = new List(); + public virtual ICollection RelationOf { get; set; } = new List(); + + + // Relationships + public List Volumes { get; set; } + public Library Library { get; set; } + public int LibraryId { get; set; } - } } diff --git a/API/Entities/SiteTheme.cs b/API/Entities/SiteTheme.cs index 87ebe95b1..a4847a7d6 100644 --- a/API/Entities/SiteTheme.cs +++ b/API/Entities/SiteTheme.cs @@ -8,7 +8,7 @@ namespace API.Entities; /// /// Represents a set of css overrides the user can upload to Kavita and will load into webui /// -public class SiteTheme : IEntityDate +public class SiteTheme : IEntityDate, ITheme { public int Id { get; set; } /// @@ -23,6 +23,7 @@ public class SiteTheme : IEntityDate /// File path to the content. Stored under . /// Must be a .css file /// + /// System provided themes use an alternative location as they are packaged with the app public string FileName { get; set; } /// /// Only one theme can have this. Will auto-set this as default for new user accounts diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index a4f51c67d..63e9dfdb9 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -40,7 +40,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/ChapterListExtensions.cs b/API/Extensions/ChapterListExtensions.cs index 6362c0571..a5d80bb92 100644 --- a/API/Extensions/ChapterListExtensions.cs +++ b/API/Extensions/ChapterListExtensions.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using API.Entities; using API.Parser; @@ -28,8 +28,8 @@ namespace API.Extensions { var specialTreatment = info.IsSpecialInfo(); return specialTreatment - ? chapters.SingleOrDefault(c => c.Range == info.Filename || (c.Files.Select(f => f.FilePath).Contains(info.FullFilePath))) - : chapters.SingleOrDefault(c => c.Range == info.Chapters); + ? chapters.FirstOrDefault(c => c.Range == info.Filename || (c.Files.Select(f => f.FilePath).Contains(info.FullFilePath))) + : chapters.FirstOrDefault(c => c.Range == info.Chapters); } } -} \ No newline at end of file +} diff --git a/API/Extensions/VolumeListExtensions.cs b/API/Extensions/VolumeListExtensions.cs index 97126e28f..8933e04a5 100644 --- a/API/Extensions/VolumeListExtensions.cs +++ b/API/Extensions/VolumeListExtensions.cs @@ -8,14 +8,6 @@ namespace API.Extensions { public static class VolumeListExtensions { - public static Volume FirstWithChapters(this IEnumerable volumes, bool inBookSeries) - { - return inBookSeries - ? volumes.FirstOrDefault(v => v.Chapters.Any()) - : volumes.OrderBy(v => v.Number, new ChapterSortComparer()) - .FirstOrDefault(v => v.Chapters.Any()); - } - /// /// Selects the first Volume to get the cover image from. For a book with only a special, the special will be returned. /// If there are both specials and non-specials, then the first non-special will be returned. diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 3765b7e47..db4bb8b3c 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -6,6 +6,7 @@ using API.DTOs.Metadata; using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Search; +using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.DTOs.Theme; using API.Entities; @@ -96,13 +97,23 @@ namespace API.Helpers opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + // CreateMap() + // .ForMember(dest => dest.Adaptations, + // opt => + // opt.MapFrom(src => src.Where(p => p.Role == PersonRole.Writer))) CreateMap(); CreateMap(); CreateMap() .ForMember(dest => dest.Theme, opt => - opt.MapFrom(src => src.Theme)); + opt.MapFrom(src => src.Theme)) + .ForMember(dest => dest.BookReaderThemeName, + opt => + opt.MapFrom(src => src.BookThemeName)) + .ForMember(dest => dest.BookReaderLayoutMode, + opt => + opt.MapFrom(src => src.PageLayoutMode)); CreateMap(); diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 36d544d4d..92551b200 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -64,16 +64,14 @@ public static class PersonHelper /// /// /// - /// Callback for all entities that was removed - public static void KeepOnlySamePeopleBetweenLists(ICollection existingPeople, ICollection removeAllExcept, Action action = null) + /// Callback for all entities that should be removed + public static void KeepOnlySamePeopleBetweenLists(IEnumerable existingPeople, ICollection removeAllExcept, Action action = null) { - var existing = existingPeople.ToList(); - foreach (var person in existing) + foreach (var person in existingPeople) { var existingPerson = removeAllExcept.FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName)); if (existingPerson == null) { - existingPeople.Remove(person); action?.Invoke(person); } } diff --git a/API/Parser/DefaultParser.cs b/API/Parser/DefaultParser.cs index d03bff199..9477fa072 100644 --- a/API/Parser/DefaultParser.cs +++ b/API/Parser/DefaultParser.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Linq; using API.Entities.Enums; using API.Services; @@ -132,11 +132,11 @@ public class DefaultParser if (!parsedVolume.Equals(Parser.DefaultVolume) || !parsedChapter.Equals(Parser.DefaultChapter)) { - if ((ret.Volumes.Equals(Parser.DefaultVolume) || string.IsNullOrEmpty(ret.Volumes)) && !parsedVolume.Equals(Parser.DefaultVolume)) + if ((string.IsNullOrEmpty(ret.Volumes) || ret.Volumes.Equals(Parser.DefaultVolume)) && !parsedVolume.Equals(Parser.DefaultVolume)) { ret.Volumes = parsedVolume; } - if ((ret.Chapters.Equals(Parser.DefaultChapter) || string.IsNullOrEmpty(ret.Chapters)) && !parsedChapter.Equals(Parser.DefaultChapter)) + if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Parser.DefaultChapter)) && !parsedChapter.Equals(Parser.DefaultChapter)) { ret.Chapters = parsedChapter; } diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 63db72658..3edcf5d7c 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -12,7 +12,7 @@ namespace API.Parser public const string DefaultVolume = "0"; private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500); - public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp)"; + public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif)"; public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt"; public const string BookFileExtensions = @"\.epub|\.pdf"; public const string MacOsMetadataFileStartsWith = @"._"; @@ -43,7 +43,7 @@ namespace API.Parser MatchOptions, RegexTimeout); - private static readonly string XmlRegexExtensions = @"\.xml"; + private const string XmlRegexExtensions = @"\.xml"; private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, MatchOptions, RegexTimeout); private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, @@ -54,7 +54,7 @@ namespace API.Parser MatchOptions, RegexTimeout); private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex CoverImageRegex = new Regex(@"(?.*)(?:, Chapter )(?\d+)", MatchOptions, RegexTimeout), - // Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz + // Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz, My Charms Are Wasted on Kuroiwa Medaka - Ch. 37.5 - Volume Extras new Regex( - @"(?.*)(\s|_|-)(?!Vol)(\s|_|-)(?:Chapter)(\s|_|-)(?\d+)", + @"(?.+?)(\s|_|-)(?!Vol)(\s|_|-)((?:Chapter)|(?:Ch\.))(\s|_|-)(?\d+)", MatchOptions, RegexTimeout), // [dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz new Regex( @@ -452,10 +452,6 @@ namespace API.Parser }; private static readonly Regex[] MangaEditionRegex = { // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz - new Regex( - @"(?({|\(|\[).* Edition(}|\)|\]))", - MatchOptions, RegexTimeout), - // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz new Regex( @"(\b|_)(?Omnibus(( |_)?Edition)?)(\b|_)?", MatchOptions, RegexTimeout), @@ -463,10 +459,6 @@ namespace API.Parser new Regex( @"(\b|_)(?Uncensored)(\b|_)", MatchOptions, RegexTimeout), - // AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz - new Regex( - @"(\b|_)(?Full(?: |_)Color)(\b|_)?", - MatchOptions, RegexTimeout), }; private static readonly Regex[] CleanupRegex = @@ -934,25 +926,7 @@ namespace API.Parser } - public static float MaximumNumberFromRange(string range) - { - try - { - if (!Regex.IsMatch(range, @"^[\d-.]+$")) - { - return (float) 0.0; - } - - var tokens = range.Replace("_", string.Empty).Split("-"); - return tokens.Max(float.Parse); - } - catch - { - return (float) 0.0; - } - } - - public static float MinimumNumberFromRange(string range) + public static float MinNumberFromRange(string range) { try { @@ -970,6 +944,24 @@ namespace API.Parser } } + public static float MaxNumberFromRange(string range) + { + try + { + if (!Regex.IsMatch(range, @"^[\d-.]+$")) + { + return (float) 0.0; + } + + var tokens = range.Replace("_", string.Empty).Split("-"); + return tokens.Max(float.Parse); + } + catch + { + return (float) 0.0; + } + } + public static string Normalize(string name) { return NormalizeRegex.Replace(name, string.Empty).ToLower(); @@ -1007,7 +999,7 @@ namespace API.Parser public static bool HasBlacklistedFolderInPath(string path) { - return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") || path.StartsWith("._"); + return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") || path.StartsWith("._") || path.Contains(".qpkg"); } diff --git a/API/Parser/ParserInfo.cs b/API/Parser/ParserInfo.cs index 07679ea25..caae49f84 100644 --- a/API/Parser/ParserInfo.cs +++ b/API/Parser/ParserInfo.cs @@ -22,6 +22,10 @@ namespace API.Parser /// public string SeriesSort { get; set; } = string.Empty; /// + /// This can be filled in from ComicInfo.xml/Epub during scanning. Will update the LocalizedName field on + /// + public string LocalizedSeries { get; set; } = string.Empty; + /// /// Represents the parsed volumes from a file. By default, will be 0 which means that nothing could be parsed. /// If Volumes is 0 and Chapters is 0, the file is a special. If Chapters is non-zero, then no volume could be parsed. /// Beastars Vol 3-4 will map to "3-4" diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index fd29ea07d..97202b71d 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; +using System.Text; using System.Threading.Tasks; using System.Xml.Serialization; using API.Archive; @@ -436,6 +437,12 @@ namespace API.Services if (Directory.Exists(extractPath)) return; + if (!_directoryService.FileSystem.File.Exists(archivePath)) + { + _logger.LogError("{Archive} does not exist on disk", archivePath); + throw new KavitaException($"{archivePath} does not exist on disk"); + } + var sw = Stopwatch.StartNew(); try diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 9e9aa0ac2..fedd2ddb9 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -20,6 +20,7 @@ using Microsoft.IO; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using VersOne.Epub; +using VersOne.Epub.Options; using Image = SixLabors.ImageSharp.Image; namespace API.Services @@ -59,6 +60,13 @@ namespace API.Services private readonly StylesheetParser _cssParser = new (); private static readonly RecyclableMemoryStreamManager StreamManager = new (); private const string CssScopeClass = ".book-content"; + public static readonly EpubReaderOptions BookReaderOptions = new() + { + PackageReaderOptions = new PackageReaderOptions() + { + IgnoreMissingToc = true + } + }; public BookService(ILogger logger, IDirectoryService directoryService, IImageService imageService) { @@ -148,8 +156,7 @@ namespace API.Services public async Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book) { - // @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be - // Scoped + // @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be Scoped var prepend = filename.Length > 0 ? filename.Replace(Path.GetFileName(filename), string.Empty) : string.Empty; var importBuilder = new StringBuilder(); foreach (Match match in Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) @@ -175,6 +182,7 @@ namespace API.Services EscapeFontFamilyReferences(ref stylesheetHtml, apiBase, prepend); + // Check if there are any background images and rewrite those urls EscapeCssImageReferences(ref stylesheetHtml, apiBase, book); @@ -237,68 +245,62 @@ namespace API.Services private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) { - var images = doc.DocumentNode.SelectNodes("//img"); - if (images != null) + var images = doc.DocumentNode.SelectNodes("//img") + ?? doc.DocumentNode.SelectNodes("//image"); + + if (images == null) return; + + foreach (var image in images) { - foreach (var image in images) + string key = null; + if (image.Attributes["src"] != null) { - if (image.Name != "img") continue; + key = "src"; + } + else if (image.Attributes["xlink:href"] != null) + { + key = "xlink:href"; + } - // Need to do for xlink:href - if (image.Attributes["src"] != null) - { - var imageFile = image.Attributes["src"].Value; - if (!book.Content.Images.ContainsKey(imageFile)) - { - // TODO: Refactor the Key code to a method to allow the hacks to be tested - var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); - if (correctedKey != null) - { - imageFile = correctedKey; - } else if (imageFile.StartsWith("..")) - { - // There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg - correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty))); - if (correctedKey != null) - { - imageFile = correctedKey; - } - } + if (string.IsNullOrEmpty(key)) continue; + var imageFile = GetKeyForImage(book, image.Attributes[key].Value); + image.Attributes.Remove(key); + image.Attributes.Add(key, $"{apiBase}" + imageFile); + // Add a custom class that the reader uses to ensure images stay within reader + image.AddClass("kavita-scale-width"); + } - } + } - image.Attributes.Remove("src"); - image.Attributes.Add("src", $"{apiBase}" + imageFile); - } + /// + /// Returns the image key associated with the file. Contains some basic fallback logic. + /// + /// + /// + /// + private static string GetKeyForImage(EpubBookRef book, string imageFile) + { + if (book.Content.Images.ContainsKey(imageFile)) return imageFile; + + var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); + if (correctedKey != null) + { + imageFile = correctedKey; + } + else if (imageFile.StartsWith("..")) + { + // There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg + correctedKey = + book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty))); + if (correctedKey != null) + { + imageFile = correctedKey; } } - images = doc.DocumentNode.SelectNodes("//image"); - if (images != null) - { - foreach (var image in images) - { - if (image.Name != "image") continue; - - if (image.Attributes["xlink:href"] != null) - { - var imageFile = image.Attributes["xlink:href"].Value; - if (!book.Content.Images.ContainsKey(imageFile)) - { - var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); - if (correctedKey != null) - { - imageFile = correctedKey; - } - } - - image.Attributes.Remove("xlink:href"); - image.Attributes.Add("xlink:href", $"{apiBase}" + imageFile); - } - } - } + return imageFile; } private static string PrepareFinalHtml(HtmlDocument doc, HtmlNode body) @@ -317,12 +319,11 @@ namespace API.Services private static void RewriteAnchors(int page, HtmlDocument doc, Dictionary mappings) { var anchors = doc.DocumentNode.SelectNodes("//a"); - if (anchors != null) + if (anchors == null) return; + + foreach (var anchor in anchors) { - foreach (var anchor in anchors) - { - BookService.UpdateLinks(anchor, mappings, page); - } + UpdateLinks(anchor, mappings, page); } } @@ -383,23 +384,44 @@ namespace API.Services try { - using var epubBook = EpubReader.OpenBook(filePath); + using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); var publicationDate = epubBook.Schema.Package.Metadata.Dates.FirstOrDefault(date => date.Event == "publication")?.Date; + if (string.IsNullOrEmpty(publicationDate)) + { + publicationDate = epubBook.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date; + } + var dateParsed = DateTime.TryParse(publicationDate, out var date); + var year = 0; + var month = 0; + var day = 0; + switch (dateParsed) + { + case true: + year = date.Year; + month = date.Month; + day = date.Day; + break; + case false when !string.IsNullOrEmpty(publicationDate) && publicationDate.Length == 4: + int.TryParse(publicationDate, out year); + break; + } + var info = new ComicInfo() { Summary = epubBook.Schema.Package.Metadata.Description, Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators.Select(c => Parser.Parser.CleanAuthor(c.Creator))), Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers), - Month = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Month : 0, - Day = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Day : 0, - Year = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Year : 0, + Month = month, + Day = day, + Year = year, Title = epubBook.Title, Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.ToLower().Trim())), LanguageISO = epubBook.Schema.Package.Metadata.Languages.FirstOrDefault() ?? string.Empty - }; + ComicInfo.CleanComicInfo(info); + // Parse tags not exposed via Library foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) { @@ -450,7 +472,7 @@ namespace API.Services return docReader.GetPageCount(); } - using var epubBook = EpubReader.OpenBook(filePath); + using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); return epubBook.Content.Html.Count; } catch (Exception ex) @@ -504,7 +526,7 @@ namespace API.Services try { - using var epubBook = EpubReader.OpenBook(filePath); + using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); // // @@ -669,8 +691,7 @@ namespace API.Services return GetPdfCoverImage(fileFilePath, fileName, outputDirectory); } - using var epubBook = EpubReader.OpenBook(fileFilePath); - + using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); try { diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index 2f4cd8cdc..4468a79a1 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -17,7 +17,8 @@ public interface IBookmarkService Task DeleteBookmarkFiles(IEnumerable bookmarks); Task BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark); Task RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto); - Task> GetBookmarkFilesById(int userId, IEnumerable bookmarkIds); + Task> GetBookmarkFilesById(IEnumerable bookmarkIds); + } public class BookmarkService : IBookmarkService @@ -141,7 +142,7 @@ public class BookmarkService : IBookmarkService return true; } - public async Task> GetBookmarkFilesById(int userId, IEnumerable bookmarkIds) + public async Task> GetBookmarkFilesById(IEnumerable bookmarkIds) { var bookmarkDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index ef2d3609a..f55d74734 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -7,6 +7,7 @@ using API.Data; using API.Entities; using API.Entities.Enums; using API.Extensions; +using Kavita.Common; using Microsoft.Extensions.Logging; namespace API.Services @@ -25,9 +26,13 @@ namespace API.Services /// /// Volumes that belong to that library. Assume the library might have been deleted before this invocation. void CleanupChapters(IEnumerable chapterIds); + void CleanupBookmarks(IEnumerable seriesIds); string GetCachedPagePath(Chapter chapter, int page); + string GetCachedBookmarkPagePath(int seriesId, int page); string GetCachedEpubFile(int chapterId, Chapter chapter); public void ExtractChapterFiles(string extractPath, IReadOnlyList files); + Task CacheBookmarkForSeries(int userId, int seriesId); + void CleanupBookmarkCache(int bookmarkDtoSeriesId); } public class CacheService : ICacheService { @@ -35,16 +40,36 @@ namespace API.Services private readonly IUnitOfWork _unitOfWork; private readonly IDirectoryService _directoryService; private readonly IReadingItemService _readingItemService; - private readonly NumericComparer _numericComparer; + private readonly IBookmarkService _bookmarkService; public CacheService(ILogger logger, IUnitOfWork unitOfWork, - IDirectoryService directoryService, IReadingItemService readingItemService) + IDirectoryService directoryService, IReadingItemService readingItemService, + IBookmarkService bookmarkService) { _logger = logger; _unitOfWork = unitOfWork; _directoryService = directoryService; _readingItemService = readingItemService; - _numericComparer = new NumericComparer(); + _bookmarkService = bookmarkService; + } + + public string GetCachedBookmarkPagePath(int seriesId, int page) + { + // Calculate what chapter the page belongs to + var path = GetBookmarkCachePath(seriesId); + var files = _directoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions); + files = files + .AsEnumerable() + .OrderByNatural(Path.GetFileNameWithoutExtension) + .ToArray(); + + if (files.Length == 0) + { + return string.Empty; + } + + // Since array is 0 based, we need to keep that in account (only affects last image) + return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page); } /// @@ -122,6 +147,12 @@ namespace API.Services else if (file.Format == MangaFormat.Epub) { removeNonImages = false; + if (!_directoryService.FileSystem.File.Exists(files[0].FilePath)) + { + _logger.LogError("{Archive} does not exist on disk", files[0].FilePath); + throw new KavitaException($"{files[0].FilePath} does not exist on disk"); + } + _directoryService.ExistOrCreate(extractPath); _directoryService.CopyFileToDirectory(files[0].FilePath, extractPath); } @@ -146,6 +177,18 @@ namespace API.Services } } + /// + /// Removes the cached files and folders for a set of chapterIds + /// + /// + public void CleanupBookmarks(IEnumerable seriesIds) + { + foreach (var series in seriesIds) + { + _directoryService.ClearAndDeleteDirectory(GetBookmarkCachePath(series)); + } + } + /// /// Returns the cache path for a given Chapter. Should be cacheDirectory/{chapterId}/ @@ -157,6 +200,11 @@ namespace API.Services return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{chapterId}/")); } + private string GetBookmarkCachePath(int seriesId) + { + return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{seriesId}_bookmarks/")); + } + /// /// Returns the absolute path of a cached page. /// @@ -181,5 +229,29 @@ namespace API.Services // Since array is 0 based, we need to keep that in account (only affects last image) return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page); } + + public async Task CacheBookmarkForSeries(int userId, int seriesId) + { + var destDirectory = _directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, seriesId + "_bookmarks"); + if (_directoryService.Exists(destDirectory)) return _directoryService.GetFiles(destDirectory).Count(); + + var bookmarkDtos = await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(userId, seriesId); + var files = (await _bookmarkService.GetBookmarkFilesById(bookmarkDtos.Select(b => b.Id))).ToList(); + _directoryService.CopyFilesToDirectory(files, destDirectory); + _directoryService.Flatten(destDirectory); + return files.Count; + } + + /// + /// Clears a cached bookmarks for a series id folder + /// + /// + public void CleanupBookmarkCache(int seriesId) + { + var destDirectory = _directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, seriesId + "_bookmarks"); + if (!_directoryService.Exists(destDirectory)) return; + + _directoryService.ClearAndDeleteDirectory(destDirectory); + } } } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index cfbd2b138..d5765bc57 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -69,7 +69,7 @@ namespace API.Services private readonly ILogger _logger; private static readonly Regex ExcludeDirectories = new Regex( - @"@eaDir|\.DS_Store|\.qpkg", + @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); diff --git a/API/Services/DownloadService.cs b/API/Services/DownloadService.cs index 7fc1418e6..76365d3d3 100644 --- a/API/Services/DownloadService.cs +++ b/API/Services/DownloadService.cs @@ -45,12 +45,16 @@ public class DownloadService : IDownloadService { contentType = Path.GetExtension(filepath).ToLowerInvariant() switch { - ".cbz" => "application/zip", - ".cbr" => "application/vnd.rar", - ".cb7" => "application/x-compressed", + ".cbz" => "application/x-cbz", + ".cbr" => "application/x-cbr", + ".cb7" => "application/x-cb7", + ".cbt" => "application/x-cbt", ".epub" => "application/epub+zip", ".7z" => "application/x-7z-compressed", ".7zip" => "application/x-7z-compressed", + ".rar" => "application/vnd.rar", + ".zip" => "application/zip", + ".tar.gz" => "application/gzip", ".pdf" => "application/pdf", _ => contentType }; diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index cc852b5bb..6578b6f63 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -26,8 +26,9 @@ public class ImageService : IImageService private readonly ILogger _logger; private readonly IDirectoryService _directoryService; public const string ChapterCoverImageRegex = @"v\d+_c\d+"; - public const string SeriesCoverImageRegex = @"series_\d+"; - public const string CollectionTagCoverImageRegex = @"tag_\d+"; + public const string SeriesCoverImageRegex = @"series\d+"; + public const string CollectionTagCoverImageRegex = @"tag\d+"; + public const string ReadingListCoverImageRegex = @"readinglist\d+"; /// @@ -143,4 +144,16 @@ public class ImageService : IImageService { return $"tag{tagId}"; } + + /// + /// Returns the name format for a reading list cover image + /// + /// + /// + public static string GetReadingListFormat(int readingListId) + { + return $"readinglist{readingListId}"; + } + + } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 590582eb5..dcd356d88 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -62,7 +62,7 @@ public class MetadataService : IMetadataService /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image private async Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate) { - var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + var firstFile = chapter.Files.MinBy(x => x.Chapter); if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked)) return false; @@ -72,7 +72,7 @@ public class MetadataService : IMetadataService _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(chapter.Id, "chapter"), false); + MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), false); return true; } @@ -97,11 +97,12 @@ public class MetadataService : IMetadataService null, volume.Created, forceUpdate)) return false; volume.Chapters ??= new List(); - var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault(); + var firstChapter = volume.Chapters.MinBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting); if (firstChapter == null) return false; volume.CoverImage = firstChapter.CoverImage; - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(volume.Id, "volume"), false); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume), false); + return true; } @@ -133,12 +134,11 @@ public class MetadataService : IMetadataService if (!_cacheHelper.CoverImageExists(coverImage)) { - coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) - .FirstOrDefault()?.CoverImage; + coverImage = series.Volumes[0].Chapters.MinBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting)?.CoverImage; } } series.CoverImage = firstCover?.CoverImage ?? coverImage; - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, "series"), false); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); } @@ -300,7 +300,7 @@ public class MetadataService : IMetadataService if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) { - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, "series"), false); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); } _logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index ab586486d..8e3f5c47d 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -9,6 +9,7 @@ using API.Data.Repositories; using API.DTOs; using API.Entities; using API.Extensions; +using API.SignalR; using Kavita.Common; using Microsoft.Extensions.Logging; @@ -33,13 +34,15 @@ public class ReaderService : IReaderService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; + private readonly IEventHub _eventHub; private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - public ReaderService(IUnitOfWork unitOfWork, ILogger logger) + public ReaderService(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub) { _unitOfWork = unitOfWork; _logger = logger; + _eventHub = eventHub; } public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId) @@ -211,9 +214,11 @@ public class ReaderService : IReaderService _unitOfWork.AppUserProgressRepository.Update(userProgress); } - if (!_unitOfWork.HasChanges()) return true; - if (await _unitOfWork.CommitAsync()) + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate, + MessageFactory.UserProgressUpdateEvent(userId, user.UserName, progressDto.SeriesId, progressDto.VolumeId, progressDto.ChapterId, progressDto.PageNum)); return true; } } @@ -470,7 +475,7 @@ public class ReaderService : IReaderService { var chapters = volume.Chapters .OrderBy(c => float.Parse(c.Number)) - .Where(c => !c.IsSpecial && Parser.Parser.MaximumNumberFromRange(c.Range) <= chapterNumber); + .Where(c => !c.IsSpecial && Parser.Parser.MaxNumberFromRange(c.Range) <= chapterNumber); MarkChaptersAsRead(user, volume.SeriesId, chapters); } } diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index d791efd55..a5130c747 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -33,7 +33,7 @@ public class ReadingItemService : IReadingItemService } /// - /// Gets the ComicInfo for the file if it exists. Null otherewise. + /// Gets the ComicInfo for the file if it exists. Null otherwise. /// /// Fully qualified path of file /// diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 25f736e99..63fb87d66 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -10,6 +10,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Metadata; using API.Entities; using API.Entities.Enums; +using API.Extensions; using API.Helpers; using API.SignalR; using Microsoft.Extensions.Logging; @@ -41,6 +42,19 @@ public class SeriesService : ISeriesService _logger = logger; } + /// + /// Returns the first chapter for a series to extract metadata from (ie Summary, etc) + /// + /// + /// + /// + public static Chapter GetFirstChapterForMetadata(Series series, bool isBookLibrary) + { + return series.Volumes.OrderBy(v => v.Number, new ChapterSortComparer()) + .SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer())) + .FirstOrDefault(); + } + public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) { try @@ -137,24 +151,22 @@ public class SeriesService : ISeriesService UpdatePeopleList(PersonRole.CoverArtist, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, series, allPeople, HandleAddPerson, () => series.Metadata.CoverArtistLocked = true); - if (!updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked) series.Metadata.AgeRatingLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.PublicationStatusLocked) series.Metadata.PublicationStatusLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.LanguageLocked) series.Metadata.LanguageLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.GenresLocked) series.Metadata.GenresLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.TagsLocked) series.Metadata.TagsLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.CharacterLocked) series.Metadata.CharacterLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.ColoristLocked) series.Metadata.ColoristLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.EditorLocked) series.Metadata.EditorLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.InkerLocked) series.Metadata.InkerLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.LettererLocked) series.Metadata.LettererLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.PencillerLocked) series.Metadata.PencillerLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.PublisherLocked) series.Metadata.PublisherLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked) series.Metadata.TranslatorLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked) series.Metadata.CoverArtistLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.WriterLocked) series.Metadata.WriterLocked = false; - if (!updateSeriesMetadataDto.SeriesMetadata.SummaryLocked) series.Metadata.SummaryLocked = false; - - + series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked; + series.Metadata.PublicationStatusLocked = updateSeriesMetadataDto.SeriesMetadata.PublicationStatusLocked; + series.Metadata.LanguageLocked = updateSeriesMetadataDto.SeriesMetadata.LanguageLocked; + series.Metadata.GenresLocked = updateSeriesMetadataDto.SeriesMetadata.GenresLocked; + series.Metadata.TagsLocked = updateSeriesMetadataDto.SeriesMetadata.TagsLocked; + series.Metadata.CharacterLocked = updateSeriesMetadataDto.SeriesMetadata.CharactersLocked; + series.Metadata.ColoristLocked = updateSeriesMetadataDto.SeriesMetadata.ColoristsLocked; + series.Metadata.EditorLocked = updateSeriesMetadataDto.SeriesMetadata.EditorsLocked; + series.Metadata.InkerLocked = updateSeriesMetadataDto.SeriesMetadata.InkersLocked; + series.Metadata.LettererLocked = updateSeriesMetadataDto.SeriesMetadata.LetterersLocked; + series.Metadata.PencillerLocked = updateSeriesMetadataDto.SeriesMetadata.PencillersLocked; + series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublishersLocked; + series.Metadata.TranslatorLocked = updateSeriesMetadataDto.SeriesMetadata.TranslatorsLocked; + series.Metadata.CoverArtistLocked = updateSeriesMetadataDto.SeriesMetadata.CoverArtistsLocked; + series.Metadata.WriterLocked = updateSeriesMetadataDto.SeriesMetadata.WritersLocked; + series.Metadata.SummaryLocked = updateSeriesMetadataDto.SeriesMetadata.SummaryLocked; if (!_unitOfWork.HasChanges()) { @@ -242,7 +254,6 @@ public class SeriesService : ISeriesService // At this point, all tags that aren't in dto have been removed. foreach (var tagTitle in tags.Select(t => t.Title)) { - // This should be normalized name var normalizedTitle = Parser.Parser.Normalize(tagTitle); var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle == normalizedTitle); if (existingTag != null) @@ -274,23 +285,21 @@ public class SeriesService : ISeriesService var isModified = false; // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different var existingTags = series.Metadata.Tags.ToList(); - foreach (var existing in existingTags) + foreach (var existing in existingTags.Where(existing => tags.SingleOrDefault(t => t.Id == existing.Id) == null)) { - if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) - { - // Remove tag - series.Metadata.Tags.Remove(existing); - isModified = true; - } + // Remove tag + series.Metadata.Tags.Remove(existing); + isModified = true; } // At this point, all tags that aren't in dto have been removed. foreach (var tagTitle in tags.Select(t => t.Title)) { - var existingTag = allTags.SingleOrDefault(t => t.Title == tagTitle); + var normalizedTitle = Parser.Parser.Normalize(tagTitle); + var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle)); if (existingTag != null) { - if (series.Metadata.Tags.All(t => t.Title != tagTitle)) + if (series.Metadata.Tags.All(t => t.NormalizedTitle != normalizedTitle)) { handleAdd(existingTag); @@ -334,7 +343,7 @@ public class SeriesService : ISeriesService var existingTag = allTags.SingleOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); if (existingTag != null) { - if (series.Metadata.People.All(t => t.Name != tag.Name && t.Role == tag.Role)) + if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => !t.Name.Equals(tag.Name))) { handleAdd(existingTag); isModified = true; @@ -447,7 +456,7 @@ public class SeriesService : ISeriesService var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)) - .OrderBy(v => float.Parse(v.Name)) + .OrderBy(v => Parser.Parser.MinNumberFromRange(v.Name)) .ToList(); var chapters = volumes.SelectMany(v => v.Chapters).ToList(); @@ -475,10 +484,10 @@ public class SeriesService : ISeriesService foreach (var chapter in chapters) { chapter.Title = FormatChapterTitle(chapter, libraryType); - if (chapter.IsSpecial) - { - specials.Add(chapter); - } + if (!chapter.IsSpecial) continue; + + if (!string.IsNullOrEmpty(chapter.TitleName)) chapter.Title = chapter.TitleName; + specials.Add(chapter); } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index d749c20ca..585bec476 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -36,7 +36,7 @@ public class TaskScheduler : ITaskScheduler private readonly IStatsService _statsService; private readonly IVersionUpdaterService _versionUpdaterService; - private readonly ISiteThemeService _siteThemeService; + private readonly IThemeService _themeService; public static BackgroundJobServer Client => new BackgroundJobServer(); private static readonly Random Rnd = new Random(); @@ -45,7 +45,7 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, - ISiteThemeService siteThemeService) + IThemeService themeService) { _cacheService = cacheService; _logger = logger; @@ -56,7 +56,7 @@ public class TaskScheduler : ITaskScheduler _cleanupService = cleanupService; _statsService = statsService; _versionUpdaterService = versionUpdaterService; - _siteThemeService = siteThemeService; + _themeService = themeService; } public async Task ScheduleTasks() @@ -131,7 +131,7 @@ public class TaskScheduler : ITaskScheduler public void ScanSiteThemes() { _logger.LogInformation("Starting Site Theme scan"); - BackgroundJob.Enqueue(() => _siteThemeService.Scan()); + BackgroundJob.Enqueue(() => _themeService.Scan()); } #endregion @@ -149,6 +149,7 @@ public class TaskScheduler : ITaskScheduler public void ScanLibrary(int libraryId) { _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); + // TODO: If a library scan is already queued up for libraryId, don't do anything BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId)); // When we do a scan, force cache to re-unpack in case page numbers change BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index ee0af81cb..647c0a066 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -64,6 +64,7 @@ namespace API.Services.Tasks await DeleteChapterCoverImages(); await SendProgress(0.7F, "Cleaning deleted cover images"); await DeleteTagCoverImages(); + await DeleteReadingListCoverImages(); await SendProgress(0.8F, "Cleaning deleted cover images"); await SendProgress(1F, "Cleanup finished"); _logger.LogInformation("Cleanup finished"); @@ -116,6 +117,16 @@ namespace API.Services.Tasks _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); } + /// + /// Removes all reading list images that are not in the database. They must follow filename pattern. + /// + public async Task DeleteReadingListCoverImages() + { + var images = await _unitOfWork.ReadingListRepository.GetAllCoverImagesAsync(); + var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.ReadingListCoverImageRegex); + _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + } + /// /// Removes all files and directories in the cache directory /// @@ -171,7 +182,7 @@ namespace API.Services.Tasks /// public Task CleanupBookmarks() { - // This is disabled for now while we test and validate a new method of deleting bookmarks + // TODO: This is disabled for now while we test and validate a new method of deleting bookmarks return Task.CompletedTask; // Search all files in bookmarks/ except bookmark files and delete those // var bookmarkDirectory = diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index ddefd00e3..92c0d6e1d 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -109,7 +109,7 @@ namespace API.Services.Tasks.Scanner } if (!string.IsNullOrEmpty(info.ComicInfo.Series)) { - info.Series = info.ComicInfo.Series; + info.Series = info.ComicInfo.Series.Trim(); } if (!string.IsNullOrEmpty(info.ComicInfo.Number)) { @@ -119,16 +119,28 @@ namespace API.Services.Tasks.Scanner // Patch is SeriesSort from ComicInfo if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort)) { - info.SeriesSort = info.ComicInfo.TitleSort; + info.SeriesSort = info.ComicInfo.TitleSort.Trim(); } if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort)) { - info.SeriesSort = info.ComicInfo.SeriesSort; + info.SeriesSort = info.ComicInfo.SeriesSort.Trim(); + } + + if (!string.IsNullOrEmpty(info.ComicInfo.LocalizedSeries)) + { + info.LocalizedSeries = info.ComicInfo.LocalizedSeries.Trim(); } } - TrackSeries(info); + try + { + TrackSeries(info); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception that occurred during tracking {FilePath}. Skipping this file", info.FullFilePath); + } } @@ -144,13 +156,16 @@ namespace API.Services.Tasks.Scanner // Check if normalized info.Series already exists and if so, update info to use that name instead info.Series = MergeName(info); + var normalizedSeries = Parser.Parser.Normalize(info.Series); + var normalizedLocalizedSeries = Parser.Parser.Normalize(info.LocalizedSeries); var existingKey = _scannedSeries.Keys.FirstOrDefault(ps => - ps.Format == info.Format && ps.NormalizedName == Parser.Parser.Normalize(info.Series)); + ps.Format == info.Format && (ps.NormalizedName == normalizedSeries + || ps.NormalizedName == normalizedLocalizedSeries)); existingKey ??= new ParsedSeries() { Format = info.Format, Name = info.Series, - NormalizedName = Parser.Parser.Normalize(info.Series) + NormalizedName = normalizedSeries }; _scannedSeries.AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => @@ -174,8 +189,12 @@ namespace API.Services.Tasks.Scanner public string MergeName(ParserInfo info) { var normalizedSeries = Parser.Parser.Normalize(info.Series); + var normalizedLocalSeries = Parser.Parser.Normalize(info.LocalizedSeries); + // We use FirstOrDefault because this was introduced late in development and users might have 2 series with both names var existingName = - _scannedSeries.SingleOrDefault(p => Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries && p.Key.Format == info.Format) + _scannedSeries.FirstOrDefault(p => + (Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries || + Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && p.Key.Format == info.Format) .Key; if (existingName != null && !string.IsNullOrEmpty(existingName.Name)) { diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 85f01aa09..4bd37d009 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -124,7 +124,9 @@ public class ScannerService : IScannerService var path = Directory.GetParent(existingFolder)?.FullName; if (!folderPaths.Contains(path) || !folderPaths.Any(p => p.Contains(path ?? string.Empty))) { - _logger.LogInformation("[ScanService] Aborted: {SeriesName} has bad naming convention and sits at root of library. Cannot scan series without deletion occuring. Correct file names to have Series Name within it or perform Scan Library", series.OriginalName); + _logger.LogCritical("[ScanService] Aborted: {SeriesName} has bad naming convention and sits at root of library. Cannot scan series without deletion occuring. Correct file names to have Series Name within it or perform Scan Library", series.OriginalName); + await _eventHub.SendMessageAsync(MessageFactory.Error, + MessageFactory.ErrorEvent($"Scan of {series.Name} aborted", $"{series.OriginalName} has bad naming convention and sits at root of library. Cannot scan series without deletion occuring. Correct file names to have Series Name within it or perform Scan Library")); return; } if (!string.IsNullOrEmpty(path)) @@ -195,7 +197,7 @@ public class ScannerService : IScannerService // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are if (folders.Any(f => !_directoryService.IsDriveMounted(f))) { - _logger.LogError("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); + _logger.LogCritical("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", @@ -213,12 +215,12 @@ public class ScannerService : IScannerService // That way logging and UI informing is all in one place with full context _logger.LogError("Some of the root folders for the library are empty. " + "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan will be aborted. " + + "Scan has be aborted. " + "Check that your mount is connected or change the library's root folder and rescan"); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.", "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan will be aborted. " + + "Scan has be aborted. " + "Check that your mount is connected or change the library's root folder and rescan")); return false; @@ -267,18 +269,14 @@ public class ScannerService : IScannerService if (!await CheckMounts(library.Name, library.Folders.Select(f => f.Path).ToList())) { _logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + return; } _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); - // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, - // MessageFactory.ScanLibraryProgressEvent(libraryId, 0F)); - var (totalFiles, scanElapsedTime, series) = await ScanFiles(library, library.Folders.Select(fp => fp.Path)); - // var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); - // var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); _logger.LogInformation("[ScannerService] Finished file scan. Updating database"); foreach (var folderPath in library.Folders) @@ -307,6 +305,7 @@ public class ScannerService : IScannerService // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, // MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); } @@ -464,10 +463,14 @@ public class ScannerService : IScannerService if (existingSeries != null) continue; var s = DbFactory.Series(infos[0].Series); - if (!string.IsNullOrEmpty(infos[0].SeriesSort)) + if (!s.SortNameLocked && !string.IsNullOrEmpty(infos[0].SeriesSort)) { s.SortName = infos[0].SeriesSort; } + if (!s.LocalizedNameLocked && !string.IsNullOrEmpty(infos[0].LocalizedSeries)) + { + s.LocalizedName = infos[0].LocalizedSeries; + } s.Format = key.Format; s.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series. newSeries.Add(s); @@ -536,6 +539,13 @@ public class ScannerService : IScannerService } } + // parsedInfos[0] is not the first volume or chapter. We need to find it + var localizedSeries = parsedInfos.Select(p => p.LocalizedSeries).FirstOrDefault(p => !string.IsNullOrEmpty(p)); + if (!series.LocalizedNameLocked && !string.IsNullOrEmpty(localizedSeries)) + { + series.LocalizedName = localizedSeries; + } + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name)); UpdateSeriesMetadata(series, allPeople, allGenres, allTags, library.Type); @@ -564,8 +574,7 @@ public class ScannerService : IScannerService private static void UpdateSeriesMetadata(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, LibraryType libraryType) { var isBook = libraryType == LibraryType.Book; - var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook); - var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles(); + var firstChapter = SeriesService.GetFirstChapterForMetadata(series, isBook); var firstFile = firstChapter?.Files.FirstOrDefault(); if (firstFile == null) return; @@ -585,14 +594,27 @@ public class ScannerService : IScannerService // Set the AgeRating as highest in all the comicInfos if (!series.Metadata.AgeRatingLocked) series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); + series.Metadata.TotalCount = chapters.Max(chapter => chapter.TotalCount); + series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count); + // To not have to rely completely on ComicInfo, try to parse out if the series is complete by checking parsed filenames as well. + if (series.Metadata.MaxCount != series.Metadata.TotalCount) + { + var maxVolume = series.Volumes.Max(v => (int) Parser.Parser.MaxNumberFromRange(v.Name)); + var maxChapter = chapters.Max(c => (int) Parser.Parser.MaxNumberFromRange(c.Range)); + if (maxVolume == series.Metadata.TotalCount) series.Metadata.MaxCount = maxVolume; + else if (maxChapter == series.Metadata.TotalCount) series.Metadata.MaxCount = maxChapter; + } + - series.Metadata.Count = chapters.Max(chapter => chapter.TotalCount); if (!series.Metadata.PublicationStatusLocked) { series.Metadata.PublicationStatus = PublicationStatus.OnGoing; - if (chapters.Max(chapter => chapter.Count) >= series.Metadata.Count && series.Metadata.Count > 0) + if (series.Metadata.MaxCount >= series.Metadata.TotalCount && series.Metadata.TotalCount > 0) { series.Metadata.PublicationStatus = PublicationStatus.Completed; + } else if (series.Metadata.TotalCount > 0 && series.Metadata.MaxCount > 0) + { + series.Metadata.PublicationStatus = PublicationStatus.Ended; } } @@ -695,9 +717,50 @@ public class ScannerService : IScannerService } } + // NOTE: The issue here is that people is just from chapter, but series metadata might already have some people on it + // I might be able to filter out people that are in locked fields? var people = chapters.SelectMany(c => c.People).ToList(); PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People, - people, person => series.Metadata.People.Remove(person)); + people, person => + { + switch (person.Role) + { + case PersonRole.Writer: + if (!series.Metadata.WriterLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Penciller: + if (!series.Metadata.PencillerLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Inker: + if (!series.Metadata.InkerLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Colorist: + if (!series.Metadata.ColoristLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Letterer: + if (!series.Metadata.LettererLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.CoverArtist: + if (!series.Metadata.CoverArtistLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Editor: + if (!series.Metadata.EditorLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Publisher: + if (!series.Metadata.PublisherLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Character: + if (!series.Metadata.CharacterLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Translator: + if (!series.Metadata.TranslatorLocked) series.Metadata.People.Remove(person); + break; + case PersonRole.Other: + default: + series.Metadata.People.Remove(person); + break; + } + }); } @@ -720,7 +783,7 @@ public class ScannerService : IScannerService _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); - UpdateChapters(volume, infos); + UpdateChapters(series, volume, infos); volume.Pages = volume.Chapters.Sum(c => c.Pages); // Update all the metadata on the Chapters @@ -767,7 +830,7 @@ public class ScannerService : IScannerService series.Name, startingVolumeCount, series.Volumes.Count); } - private void UpdateChapters(Volume volume, IList parsedInfos) + private void UpdateChapters(Series series, Volume volume, IList parsedInfos) { // Add new chapters foreach (var info in parsedInfos) @@ -789,32 +852,20 @@ public class ScannerService : IScannerService { _logger.LogDebug( "[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); - volume.Chapters.Add(DbFactory.Chapter(info)); + chapter = DbFactory.Chapter(info); + volume.Chapters.Add(chapter); + series.LastChapterAdded = DateTime.Now; } else { chapter.UpdateFrom(info); } - } - - // Add files - foreach (var info in parsedInfos) - { - var specialTreatment = info.IsSpecialInfo(); - Chapter chapter; - try - { - chapter = volume.Chapters.GetChapterByRange(info); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception parsing chapter. Skipping {SeriesName} Vol {VolumeNumber} Chapter {ChapterNumber} - Special treatment: {NeedsSpecialTreatment}", info.Series, volume.Name, info.Chapters, specialTreatment); - continue; - } if (chapter == null) continue; + // Add files + var specialTreatment = info.IsSpecialInfo(); AddOrUpdateFileForChapter(chapter, info); - chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + string.Empty; + chapter.Number = Parser.Parser.MinNumberFromRange(info.Chapters) + string.Empty; chapter.Range = specialTreatment ? info.Filename : info.Chapters; } @@ -861,7 +912,7 @@ public class ScannerService : IScannerService private void UpdateChapterFromComicInfo(Chapter chapter, ICollection allPeople, ICollection allTags, ICollection allGenres, ComicInfo? info) { - var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + var firstFile = chapter.Files.MinBy(x => x.Chapter); if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) return; @@ -895,10 +946,16 @@ public class ScannerService : IScannerService chapter.TotalCount = comicInfo.Count; } + // This needs to check against both Number and Volume to calculate Count if (!string.IsNullOrEmpty(comicInfo.Number) && float.Parse(comicInfo.Number) > 0) { chapter.Count = (int) Math.Floor(float.Parse(comicInfo.Number)); } + if (!string.IsNullOrEmpty(comicInfo.Volume) && float.Parse(comicInfo.Volume) > 0) + { + chapter.Count = Math.Max(chapter.Count, (int) Math.Floor(float.Parse(comicInfo.Volume))); + } + diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index e0e1bc2d8..553730d3a 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -7,24 +6,23 @@ using API.Entities; using API.Entities.Enums.Theme; using API.SignalR; using Kavita.Common; -using Microsoft.AspNetCore.SignalR; namespace API.Services.Tasks; -public interface ISiteThemeService +public interface IThemeService { Task GetContent(int themeId); Task Scan(); Task UpdateDefault(int themeId); } -public class SiteThemeService : ISiteThemeService +public class ThemeService : IThemeService { private readonly IDirectoryService _directoryService; private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; - public SiteThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IEventHub eventHub) + public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IEventHub eventHub) { _directoryService = directoryService; _unitOfWork = unitOfWork; @@ -36,7 +34,6 @@ public class SiteThemeService : ISiteThemeService /// /// /// - /// public async Task GetContent(int themeId) { var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); @@ -55,7 +52,8 @@ public class SiteThemeService : ISiteThemeService { _directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory); var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList(); - var themeFiles = _directoryService.GetFilesWithExtension(Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css") + var themeFiles = _directoryService + .GetFilesWithExtension(Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css") .Where(name => !reservedNames.Contains(Parser.Parser.Normalize(name))).ToList(); var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); @@ -91,7 +89,8 @@ public class SiteThemeService : ISiteThemeService }); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(themeFile), themeName, ProgressEventType.Updated)); + MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(themeFile), themeName, + ProgressEventType.Updated)); } @@ -116,10 +115,10 @@ public class SiteThemeService : ISiteThemeService } await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.SiteThemeProgressEvent("", "", ProgressEventType.Ended)); - + MessageFactory.SiteThemeProgressEvent("", "", ProgressEventType.Ended)); } + /// /// Removes the theme and any references to it from Pref and sets them to the default at the time. /// This commits to DB. diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 53833e3f3..be3ab0acf 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -7,6 +7,14 @@ using API.Entities; namespace API.SignalR { + public static class MessageFactoryEntityTypes + { + public const string Series = "series"; + public const string Volume = "volume"; + public const string Chapter = "chapter"; + public const string CollectionTag = "collection"; + public const string ReadingList = "readingList"; + } public static class MessageFactory { /// @@ -58,6 +66,10 @@ namespace API.SignalR /// private const string SiteThemeProgress = "SiteThemeProgress"; /// + /// A custom book theme was removed or added + /// + private const string BookThemeProgress = "BookThemeProgress"; + /// /// A type of event that has progress (determinate or indeterminate). /// The underlying event will have a name to give details on how to handle. /// @@ -78,6 +90,11 @@ namespace API.SignalR /// When a library is created/deleted in the Server /// public const string LibraryModified = "LibraryModified"; + /// + /// A user's progress was modified + /// + public const string UserProgressUpdate = "UserProgressUpdate"; + public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName) @@ -320,6 +337,25 @@ namespace API.SignalR }; } + public static SignalRMessage UserProgressUpdateEvent(int userId, string username, int seriesId, int volumeId, int chapterId, int pagesRead) + { + return new SignalRMessage() + { + Name = UserProgressUpdate, + Title = "Updating User Progress", + Progress = ProgressType.None, + Body = new + { + UserId = userId, + Username = username, + SeriesId = seriesId, + VolumeId = volumeId, + ChapterId = chapterId, + PagesRead = pagesRead, + } + }; + } + public static SignalRMessage SiteThemeProgressEvent(string subtitle, string themeName, string eventType) { return new SignalRMessage() @@ -335,5 +371,21 @@ namespace API.SignalR } }; } + + public static SignalRMessage BookThemeProgressEvent(string subtitle, string themeName, string eventType) + { + return new SignalRMessage() + { + Name = BookThemeProgress, + Title = "Scanning Book Theme", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = new + { + ThemeName = themeName, + } + }; + } } } diff --git a/API/Startup.cs b/API/Startup.cs index e1dd28a81..66f704489 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -147,6 +147,7 @@ namespace API // Apply all migrations on startup var logger = serviceProvider.GetRequiredService>(); var userManager = serviceProvider.GetRequiredService>(); + var themeService = serviceProvider.GetRequiredService(); await MigrateBookmarks.Migrate(directoryService, unitOfWork, logger, cacheService); @@ -154,6 +155,8 @@ namespace API // Only run this if we are upgrading await MigrateChangePasswordRoles.Migrate(unitOfWork, userManager); + await MigrateRemoveExtraThemes.Migrate(unitOfWork, themeService); + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); installVersion.Value = BuildInfo.Version.ToString(); diff --git a/API/config/kavita.db.dik b/API/config/kavita.db.dik deleted file mode 100644 index 4d71b8263..000000000 Binary files a/API/config/kavita.db.dik and /dev/null differ diff --git a/API/config/kavita.db.new b/API/config/kavita.db.new deleted file mode 100644 index 774d54743..000000000 Binary files a/API/config/kavita.db.new and /dev/null differ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b0417947..7e1fae0be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,4 +54,10 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit - feature/parser-enhancements (Great) - bugfix/book-issues (Great) +### Swagger API ### +If you just want to play with Swagger, you can just +- cd Kavita/API +- dotnet run -c Debug +- Go to http://localhost:5000/swagger/index.html + If you have any questions about any of this, please let us know. diff --git a/Dockerfile b/Dockerfile index 11db76ef8..7233214f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ EXPOSE 5000 WORKDIR /kavita -HEALTHCHECK --interval=300s --timeout=15s --start-period=30s --retries=3 CMD curl --fail http://localhost:5000 || exit 1 +HEALTHCHECK --interval=30s --timeout=15s --start-period=30s --retries=3 CMD curl --fail http://localhost:5000 || exit 1 ENTRYPOINT [ "/bin/bash" ] CMD ["/entrypoint.sh"] diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 8ceb83869..e838ff780 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -4,19 +4,19 @@ net6.0 kavitareader.com Kavita - 0.5.2.5 + 0.5.3.0 en - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - \ No newline at end of file + diff --git a/README.md b/README.md index abc2ee070..e4f47b795 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # []() Kavita
-!![high level view](https://user-images.githubusercontent.com/735851/129777364-2c82d01e-5c03-4daf-b203-92b1d48e5b7b.gif) +![new_github_preview_stills](https://user-images.githubusercontent.com/735851/169657008-37812c18-5490-4e2a-9dcb-4806f8c87c69.gif) -Kavita is a fast, feature rich, cross platform reading server. Built with a focus for manga, -and the goal of being a full solution for all your reading needs. Setup your own server and share +Kavita is a fast, feature rich, cross platform reading server. Built with a focus for manga, +and the goal of being a full solution for all your reading needs. Setup your own server and share your reading collection with your friends and family! [![Release](https://img.shields.io/github/release/Kareadita/Kavita.svg?style=flat&maxAge=3600)](https://github.com/Kareadita/Kavita/releases) @@ -20,7 +20,7 @@ your reading collection with your friends and family! ## Goals - [x] Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar, 7zip, raw images) and Books (epub, pdf) - [x] First class responsive readers that work great on any device (phone, tablet, desktop) -- [x] Dark and Light themes +- [x] Dark mode and customizable theming support - [ ] Provide hooks into metadata providers to fetch metadata for Comics, Manga, and Books - [x] Metadata should allow for collections, want to read integration from 3rd party services, genres. - [x] Ability to manage users, access, and ratings @@ -43,47 +43,19 @@ Password: Demouser64 ``` ## Setup -### Non-Docker -- Unzip the archive for your target OS -- Place in a directory that is writable. If on windows, do not place in Program Files -- Linux users must ensure the directory & kavita.db is writable by Kavita (might require starting server once) -- Run Kavita -- If you are updating, copy everything over into install location. All Kavita data is stored in config/, so nothing will be overwritten. -- Open localhost:5000 and setup your account and libraries in the UI. -### Docker -Running your Kavita server in docker is super easy! Barely an inconvenience. You can run it with this command: - -``` -docker run --name kavita -p 5000:5000 \ --v /your/manga/directory:/manga \ --v /kavita/data/directory:/kavita/config \ ---restart unless-stopped \ --d kizaing/kavita:latest -``` - -You can also run it via the docker-compose file: - -``` -version: '3' -services: - kavita: - image: kizaing/kavita:latest - container_name: kavita - volumes: - - ./manga:/manga - - ./config:/kavita/config - ports: - - "5000:5000" - restart: unless-stopped -``` +The easiest way to get started is to visit our Wiki which has up-to-date information on a variety of +install methods and platforms. +[https://wiki.kavitareader.com/en/install](https://wiki.kavitareader.com/en/install) **Note: Kavita is under heavy development and is being updated all the time, so the tag for current builds is `:nightly`. The `:latest` tag will be the latest stable release.** ## Feature Requests -Got a great idea? Throw it up on our [Feature Request site](https://feats.kavitareader.com/) or vote on another idea. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects) first for a list of planned features. +Got a great idea? Throw it up on our [Feature Request site](https://feats.kavitareader.com/) or vote on another idea. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects) first for a list of planned features before you submit an idea. ## Notice -Kavita is being actively developed and should be considered beta software until the 1.0 release. Kavita may be subject to changes in how the platform functions as it is being built out toward the vision. You may lose data and have to restart. The Kavita team strives to avoid any data loss. +Kavita is being actively developed and should be considered beta software until the 1.0 release. +Kavita may be subject to changes in how the platform functions as it is being built out toward the +vision. You may lose data and have to restart. The Kavita team strives to avoid any data loss. ## Contributors @@ -94,7 +66,7 @@ This project exists thanks to all the people who contribute. [Contribute](CONTRI ## Donate -If you like Kavita, have gotten good use out of it or feel like you want to say thanks with a few bucks, feel free to donate. Money will go towards +If you like Kavita, have gotten good use out of it, or feel like you want to say thanks with a few bucks, feel free to donate. Money will go towards expenses related to Kavita. Back us through [OpenCollective](https://opencollective.com/Kavita#backer). You can also use [Paypal](https://www.paypal.com/paypalme/majora2007?locale.x=en_US), however your name will not show below. ## Backers @@ -119,7 +91,7 @@ Thank you to [ JetBrains](http: * [ dotTrace](http://www.jetbrains.com/dottrace/) ## Palace-Designs -We would like to extend a big thank you to [](https://www.palace-designs.com/) who hosts our infrastructure pro-bono. +We would like to extend a big thank you to [](https://www.palace-designs.com/) who hosts our infrastructure pro-bono. ### License diff --git a/UI/Web/.github/workflows/playwright.yml b/UI/Web/.github/workflows/playwright.yml new file mode 100644 index 000000000..4d7b32a27 --- /dev/null +++ b/UI/Web/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '14.x' + - name: Install dependencies + run: npm ci + - name: Install Playwright + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v2 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/UI/Web/.gitignore b/UI/Web/.gitignore new file mode 100644 index 000000000..dbd64df83 --- /dev/null +++ b/UI/Web/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/UI/Web/README.md b/UI/Web/README.md index 083807057..3e2904700 100644 --- a/UI/Web/README.md +++ b/UI/Web/README.md @@ -20,7 +20,9 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github. ## Running end-to-end tests -Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). +~~Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).~~ + +Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e tests. ## Further help diff --git a/UI/Web/adminStorageState.json b/UI/Web/adminStorageState.json new file mode 100644 index 000000000..f4ec35503 --- /dev/null +++ b/UI/Web/adminStorageState.json @@ -0,0 +1,4 @@ +{ + "cookies": [], + "origins": [] +} \ No newline at end of file diff --git a/UI/Web/e2e/example.spec.ts.txt b/UI/Web/e2e/example.spec.ts.txt new file mode 100644 index 000000000..0e4037d83 --- /dev/null +++ b/UI/Web/e2e/example.spec.ts.txt @@ -0,0 +1,398 @@ +// import { test, expect, Page } from '@playwright/test'; + +// test.beforeEach(async ({ page }) => { +// await page.goto('https://demo.playwright.dev/todomvc'); +// }); + +// const TODO_ITEMS = [ +// 'buy some cheese', +// 'feed the cat', +// 'book a doctors appointment' +// ]; + +// test.describe('New Todo', () => { +// test('should allow me to add todo items', async ({ page }) => { +// // Create 1st todo. +// await page.locator('.new-todo').fill(TODO_ITEMS[0]); +// await page.locator('.new-todo').press('Enter'); + +// // Make sure the list only has one todo item. +// await expect(page.locator('.view label')).toHaveText([ +// TODO_ITEMS[0] +// ]); + +// // Create 2nd todo. +// await page.locator('.new-todo').fill(TODO_ITEMS[1]); +// await page.locator('.new-todo').press('Enter'); + +// // Make sure the list now has two todo items. +// await expect(page.locator('.view label')).toHaveText([ +// TODO_ITEMS[0], +// TODO_ITEMS[1] +// ]); + +// await checkNumberOfTodosInLocalStorage(page, 2); +// }); + +// test('should clear text input field when an item is added', async ({ page }) => { +// // Create one todo item. +// await page.locator('.new-todo').fill(TODO_ITEMS[0]); +// await page.locator('.new-todo').press('Enter'); + +// // Check that input is empty. +// await expect(page.locator('.new-todo')).toBeEmpty(); +// await checkNumberOfTodosInLocalStorage(page, 1); +// }); + +// test('should append new items to the bottom of the list', async ({ page }) => { +// // Create 3 items. +// await createDefaultTodos(page); + +// // Check test using different methods. +// await expect(page.locator('.todo-count')).toHaveText('3 items left'); +// await expect(page.locator('.todo-count')).toContainText('3'); +// await expect(page.locator('.todo-count')).toHaveText(/3/); + +// // Check all items in one call. +// await expect(page.locator('.view label')).toHaveText(TODO_ITEMS); +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test('should show #main and #footer when items added', async ({ page }) => { +// await page.locator('.new-todo').fill(TODO_ITEMS[0]); +// await page.locator('.new-todo').press('Enter'); + +// await expect(page.locator('.main')).toBeVisible(); +// await expect(page.locator('.footer')).toBeVisible(); +// await checkNumberOfTodosInLocalStorage(page, 1); +// }); +// }); + +// test.describe('Mark all as completed', () => { +// test.beforeEach(async ({ page }) => { +// await createDefaultTodos(page); +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test.afterEach(async ({ page }) => { +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test('should allow me to mark all items as completed', async ({ page }) => { +// // Complete all todos. +// await page.locator('.toggle-all').check(); + +// // Ensure all todos have 'completed' class. +// await expect(page.locator('.todo-list li')).toHaveClass(['completed', 'completed', 'completed']); +// await checkNumberOfCompletedTodosInLocalStorage(page, 3); +// }); + +// test('should allow me to clear the complete state of all items', async ({ page }) => { +// // Check and then immediately uncheck. +// await page.locator('.toggle-all').check(); +// await page.locator('.toggle-all').uncheck(); + +// // Should be no completed classes. +// await expect(page.locator('.todo-list li')).toHaveClass(['', '', '']); +// }); + +// test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { +// const toggleAll = page.locator('.toggle-all'); +// await toggleAll.check(); +// await expect(toggleAll).toBeChecked(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 3); + +// // Uncheck first todo. +// const firstTodo = page.locator('.todo-list li').nth(0); +// await firstTodo.locator('.toggle').uncheck(); + +// // Reuse toggleAll locator and make sure its not checked. +// await expect(toggleAll).not.toBeChecked(); + +// await firstTodo.locator('.toggle').check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 3); + +// // Assert the toggle all is checked again. +// await expect(toggleAll).toBeChecked(); +// }); +// }); + +// test.describe('Item', () => { + +// test('should allow me to mark items as complete', async ({ page }) => { +// // Create two items. +// for (const item of TODO_ITEMS.slice(0, 2)) { +// await page.locator('.new-todo').fill(item); +// await page.locator('.new-todo').press('Enter'); +// } + +// // Check first item. +// const firstTodo = page.locator('.todo-list li').nth(0); +// await firstTodo.locator('.toggle').check(); +// await expect(firstTodo).toHaveClass('completed'); + +// // Check second item. +// const secondTodo = page.locator('.todo-list li').nth(1); +// await expect(secondTodo).not.toHaveClass('completed'); +// await secondTodo.locator('.toggle').check(); + +// // Assert completed class. +// await expect(firstTodo).toHaveClass('completed'); +// await expect(secondTodo).toHaveClass('completed'); +// }); + +// test('should allow me to un-mark items as complete', async ({ page }) => { +// // Create two items. +// for (const item of TODO_ITEMS.slice(0, 2)) { +// await page.locator('.new-todo').fill(item); +// await page.locator('.new-todo').press('Enter'); +// } + +// const firstTodo = page.locator('.todo-list li').nth(0); +// const secondTodo = page.locator('.todo-list li').nth(1); +// await firstTodo.locator('.toggle').check(); +// await expect(firstTodo).toHaveClass('completed'); +// await expect(secondTodo).not.toHaveClass('completed'); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); + +// await firstTodo.locator('.toggle').uncheck(); +// await expect(firstTodo).not.toHaveClass('completed'); +// await expect(secondTodo).not.toHaveClass('completed'); +// await checkNumberOfCompletedTodosInLocalStorage(page, 0); +// }); + +// test('should allow me to edit an item', async ({ page }) => { +// await createDefaultTodos(page); + +// const todoItems = page.locator('.todo-list li'); +// const secondTodo = todoItems.nth(1); +// await secondTodo.dblclick(); +// await expect(secondTodo.locator('.edit')).toHaveValue(TODO_ITEMS[1]); +// await secondTodo.locator('.edit').fill('buy some sausages'); +// await secondTodo.locator('.edit').press('Enter'); + +// // Explicitly assert the new text value. +// await expect(todoItems).toHaveText([ +// TODO_ITEMS[0], +// 'buy some sausages', +// TODO_ITEMS[2] +// ]); +// await checkTodosInLocalStorage(page, 'buy some sausages'); +// }); +// }); + +// test.describe('Editing', () => { +// test.beforeEach(async ({ page }) => { +// await createDefaultTodos(page); +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test('should hide other controls when editing', async ({ page }) => { +// const todoItem = page.locator('.todo-list li').nth(1); +// await todoItem.dblclick(); +// await expect(todoItem.locator('.toggle')).not.toBeVisible(); +// await expect(todoItem.locator('label')).not.toBeVisible(); +// await checkNumberOfTodosInLocalStorage(page, 3); +// }); + +// test('should save edits on blur', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).dblclick(); +// await todoItems.nth(1).locator('.edit').fill('buy some sausages'); +// await todoItems.nth(1).locator('.edit').dispatchEvent('blur'); + +// await expect(todoItems).toHaveText([ +// TODO_ITEMS[0], +// 'buy some sausages', +// TODO_ITEMS[2], +// ]); +// await checkTodosInLocalStorage(page, 'buy some sausages'); +// }); + +// test('should trim entered text', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).dblclick(); +// await todoItems.nth(1).locator('.edit').fill(' buy some sausages '); +// await todoItems.nth(1).locator('.edit').press('Enter'); + +// await expect(todoItems).toHaveText([ +// TODO_ITEMS[0], +// 'buy some sausages', +// TODO_ITEMS[2], +// ]); +// await checkTodosInLocalStorage(page, 'buy some sausages'); +// }); + +// test('should remove the item if an empty text string was entered', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).dblclick(); +// await todoItems.nth(1).locator('.edit').fill(''); +// await todoItems.nth(1).locator('.edit').press('Enter'); + +// await expect(todoItems).toHaveText([ +// TODO_ITEMS[0], +// TODO_ITEMS[2], +// ]); +// }); + +// test('should cancel edits on escape', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).dblclick(); +// await todoItems.nth(1).locator('.edit').press('Escape'); +// await expect(todoItems).toHaveText(TODO_ITEMS); +// }); +// }); + +// test.describe('Counter', () => { +// test('should display the current number of todo items', async ({ page }) => { +// await page.locator('.new-todo').fill(TODO_ITEMS[0]); +// await page.locator('.new-todo').press('Enter'); +// await expect(page.locator('.todo-count')).toContainText('1'); + +// await page.locator('.new-todo').fill(TODO_ITEMS[1]); +// await page.locator('.new-todo').press('Enter'); +// await expect(page.locator('.todo-count')).toContainText('2'); + +// await checkNumberOfTodosInLocalStorage(page, 2); +// }); +// }); + +// test.describe('Clear completed button', () => { +// test.beforeEach(async ({ page }) => { +// await createDefaultTodos(page); +// }); + +// test('should display the correct text', async ({ page }) => { +// await page.locator('.todo-list li .toggle').first().check(); +// await expect(page.locator('.clear-completed')).toHaveText('Clear completed'); +// }); + +// test('should remove completed items when clicked', async ({ page }) => { +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(1).locator('.toggle').check(); +// await page.locator('.clear-completed').click(); +// await expect(todoItems).toHaveCount(2); +// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); +// }); + +// test('should be hidden when there are no items that are completed', async ({ page }) => { +// await page.locator('.todo-list li .toggle').first().check(); +// await page.locator('.clear-completed').click(); +// await expect(page.locator('.clear-completed')).toBeHidden(); +// }); +// }); + +// test.describe('Persistence', () => { +// test('should persist its data', async ({ page }) => { +// for (const item of TODO_ITEMS.slice(0, 2)) { +// await page.locator('.new-todo').fill(item); +// await page.locator('.new-todo').press('Enter'); +// } + +// const todoItems = page.locator('.todo-list li'); +// await todoItems.nth(0).locator('.toggle').check(); +// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); +// await expect(todoItems).toHaveClass(['completed', '']); + +// // Ensure there is 1 completed item. +// checkNumberOfCompletedTodosInLocalStorage(page, 1); + +// // Now reload. +// await page.reload(); +// await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); +// await expect(todoItems).toHaveClass(['completed', '']); +// }); +// }); + +// test.describe('Routing', () => { +// test.beforeEach(async ({ page }) => { +// await createDefaultTodos(page); +// // make sure the app had a chance to save updated todos in storage +// // before navigating to a new view, otherwise the items can get lost :( +// // in some frameworks like Durandal +// await checkTodosInLocalStorage(page, TODO_ITEMS[0]); +// }); + +// test('should allow me to display active items', async ({ page }) => { +// await page.locator('.todo-list li .toggle').nth(1).check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); +// await page.locator('.filters >> text=Active').click(); +// await expect(page.locator('.todo-list li')).toHaveCount(2); +// await expect(page.locator('.todo-list li')).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); +// }); + +// test('should respect the back button', async ({ page }) => { +// await page.locator('.todo-list li .toggle').nth(1).check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); + +// await test.step('Showing all items', async () => { +// await page.locator('.filters >> text=All').click(); +// await expect(page.locator('.todo-list li')).toHaveCount(3); +// }); + +// await test.step('Showing active items', async () => { +// await page.locator('.filters >> text=Active').click(); +// }); + +// await test.step('Showing completed items', async () => { +// await page.locator('.filters >> text=Completed').click(); +// }); + +// await expect(page.locator('.todo-list li')).toHaveCount(1); +// await page.goBack(); +// await expect(page.locator('.todo-list li')).toHaveCount(2); +// await page.goBack(); +// await expect(page.locator('.todo-list li')).toHaveCount(3); +// }); + +// test('should allow me to display completed items', async ({ page }) => { +// await page.locator('.todo-list li .toggle').nth(1).check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); +// await page.locator('.filters >> text=Completed').click(); +// await expect(page.locator('.todo-list li')).toHaveCount(1); +// }); + +// test('should allow me to display all items', async ({ page }) => { +// await page.locator('.todo-list li .toggle').nth(1).check(); +// await checkNumberOfCompletedTodosInLocalStorage(page, 1); +// await page.locator('.filters >> text=Active').click(); +// await page.locator('.filters >> text=Completed').click(); +// await page.locator('.filters >> text=All').click(); +// await expect(page.locator('.todo-list li')).toHaveCount(3); +// }); + +// test('should highlight the currently applied filter', async ({ page }) => { +// await expect(page.locator('.filters >> text=All')).toHaveClass('selected'); +// await page.locator('.filters >> text=Active').click(); +// // Page change - active items. +// await expect(page.locator('.filters >> text=Active')).toHaveClass('selected'); +// await page.locator('.filters >> text=Completed').click(); +// // Page change - completed items. +// await expect(page.locator('.filters >> text=Completed')).toHaveClass('selected'); +// }); +// }); + +// async function createDefaultTodos(page: Page) { +// for (const item of TODO_ITEMS) { +// await page.locator('.new-todo').fill(item); +// await page.locator('.new-todo').press('Enter'); +// } +// } + +// async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { +// return await page.waitForFunction(e => { +// return JSON.parse(localStorage['react-todos']).length === e; +// }, expected); +// } + +// async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { +// return await page.waitForFunction(e => { +// return JSON.parse(localStorage['react-todos']).filter(i => i.completed).length === e; +// }, expected); +// } + +// async function checkTodosInLocalStorage(page: Page, title: string) { +// return await page.waitForFunction(t => { +// return JSON.parse(localStorage['react-todos']).map(i => i.title).includes(t); +// }, title); +// } diff --git a/UI/Web/e2e/src/app.e2e-spec.ts b/UI/Web/e2e/src/app.e2e-spec.ts deleted file mode 100644 index fb7ac99be..000000000 --- a/UI/Web/e2e/src/app.e2e-spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AppPage } from './app.po'; -import { browser, logging } from 'protractor'; - -describe('workspace-project App', () => { - let page: AppPage; - - beforeEach(() => { - page = new AppPage(); - }); - - it('should display welcome message', async () => { - await page.navigateTo(); - expect(await page.getTitleText()).toEqual('kavita-webui app is running!'); - }); - - afterEach(async () => { - // Assert that there are no errors emitted from the browser - const logs = await browser.manage().logs().get(logging.Type.BROWSER); - expect(logs).not.toContain(jasmine.objectContaining({ - level: logging.Level.SEVERE, - } as logging.Entry)); - }); -}); diff --git a/UI/Web/e2e/src/app.spec.ts b/UI/Web/e2e/src/app.spec.ts new file mode 100644 index 000000000..2f07e3839 --- /dev/null +++ b/UI/Web/e2e/src/app.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('When not authenticated, should be redirected to login page', async ({ page }) => { + await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' }); + expect(page.url()).toBe('http://localhost:4200/login'); +}); + +test('When not authenticated, should be redirected to login page from an authenticated page', async ({ page }) => { + await page.goto('http://localhost:4200/library', { waitUntil: 'networkidle' }); + expect(page.url()).toBe('http://localhost:4200/login'); +}); + +// Not sure how to test when we need localStorage: https://github.com/microsoft/playwright/issues/6258 +// test('When authenticated, should be redirected to library page', async ({ page }) => { +// await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' }); +// console.log('url: ', page.url()); +// expect(page.url()).toBe('http://localhost:4200/library'); +// }); \ No newline at end of file diff --git a/UI/Web/e2e/src/login/login.spec.ts b/UI/Web/e2e/src/login/login.spec.ts new file mode 100644 index 000000000..8a504f3f9 --- /dev/null +++ b/UI/Web/e2e/src/login/login.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; + +test('When not authenticated, should be redirected to login page', async ({ page }) => { + await page.goto('http://localhost:4200/', { waitUntil: 'networkidle' }); + expect(page.url()).toBe('http://localhost:4200/login'); +}); + +test('Should be able to log in', async ({ page }) => { + + await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' }); + const username = page.locator('#username'); + expect(username).toBeEditable(); + const password = page.locator('#password'); + expect(password).toBeEditable(); + + await username.type('Joe'); + await password.type('P4ssword'); + + const button = page.locator('button[type="submit"]'); + await button.click(); + + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(200); + expect(page.url()).toBe('http://localhost:4200/library'); +}); + +test('Should get a toastr when no username', async ({ page }) => { + + await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' }); + const username = page.locator('#username'); + expect(username).toBeEditable(); + + await username.type(''); + + const button = page.locator('button[type="submit"]'); + await button.click(); + + await page.waitForTimeout(100); + const toastr = page.locator('#toast-container div[role="alertdialog"]') + await expect(toastr).toHaveText('Invalid username'); + + expect(page.url()).toBe('http://localhost:4200/login'); +}); \ No newline at end of file diff --git a/UI/Web/e2e/src/registration/forgot-password/forgot-password.spec.ts b/UI/Web/e2e/src/registration/forgot-password/forgot-password.spec.ts new file mode 100644 index 000000000..937651d9b --- /dev/null +++ b/UI/Web/e2e/src/registration/forgot-password/forgot-password.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +test('When on login page, clicking Forgot Password should redirect', async ({ page }) => { + await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' }); + + await page.click('a[routerlink="/registration/reset-password"]') + await page.waitForLoadState('networkidle'); + + expect(page.url()).toBe('http://localhost:4200/registration/reset-password'); +}); + +test('Going directly to reset url should stay on the page', async ({page}) => { + await page.goto('http://localhost:4200/registration/reset-password', { waitUntil: 'networkidle' }); + const email = page.locator('#email'); + expect(email).toBeEditable(); +}) + +test('Submitting an email, should give a prompt to user, redirect back to login', async ({ page }) => { + await page.goto('http://localhost:4200/registration/reset-password', { waitUntil: 'networkidle' }); + + const email = page.locator('#email'); + expect(email).toBeEditable(); + + await email.type('XXX@gmail.com'); + + const button = page.locator('button[type="submit"]'); + await button.click(); + + const toastr = page.locator('#toast-container div[role="alertdialog"]') + await expect(toastr).toHaveText('An email will be sent to the email if it exists in our database'); + await page.waitForLoadState('networkidle'); + + expect(page.url()).toBe('http://localhost:4200/login'); +}); \ No newline at end of file diff --git a/UI/Web/e2e/src/side-nav/side-nav.spec.ts b/UI/Web/e2e/src/side-nav/side-nav.spec.ts new file mode 100644 index 000000000..b74d07eea --- /dev/null +++ b/UI/Web/e2e/src/side-nav/side-nav.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +test.use({ storageState: 'storage/admin.json' }); + +test('When on login page, side nav should not render', async ({ page }) => { + await page.goto('http://localhost:4200/login', { waitUntil: 'networkidle' }); + await expect(page.locator(".side-nav")).toHaveCount(0) +}); + +test('When on library page, side nav should render', async ({ page }) => { + await page.goto('http://localhost:4200/library', { waitUntil: 'networkidle' }); + await expect(page.locator(".side-nav")).toHaveCount(1) +}); \ No newline at end of file diff --git a/UI/Web/global-setup.ts b/UI/Web/global-setup.ts new file mode 100644 index 000000000..8db0f8bde --- /dev/null +++ b/UI/Web/global-setup.ts @@ -0,0 +1,46 @@ +import { Browser, chromium, FullConfig, request } from '@playwright/test'; + +async function globalSetup(config: FullConfig) { + let requestContext = await request.newContext(); + var token = await requestContext.post('http://localhost:5000/account/login', { + form: { + 'user': 'Joe', + 'password': 'P4ssword' + } + }); + console.log(token.json()); + // Save signed-in state to 'storageState.json'. + //await requestContext.storageState({ path: 'adminStorageState.json' }); + await requestContext.dispose(); + + requestContext = await request.newContext(); + await requestContext.post('http://localhost:5000/account/login', { + form: { + 'user': 'nonadmin', + 'password': 'P4ssword' + } + }); + // Save signed-in state to 'storageState.json'. + //await requestContext.storageState({ path: 'nonAdminStorageState.json' }); + await requestContext.dispose(); +} + + + +// async function globalSetup (config: FullConfig) { +// const browser = await chromium.launch() +// await saveStorage(browser, 'nonadmin', 'P4ssword', 'storage/user.json') +// await saveStorage(browser, 'Joe', 'P4ssword', 'storage/admin.json') +// await browser.close() +// } + +async function saveStorage (browser: Browser, username: string, password: string, saveStoragePath: string) { + const page = await browser.newPage() + await page.goto('http://localhost:5000/account/login') + await page.type('#username', username) + await page.type('#password', password) + await page.click('button[type="submit"]') + await page.context().storageState({ path: saveStoragePath }) +} + +export default globalSetup; \ No newline at end of file diff --git a/UI/Web/nonAdminStorageState.json b/UI/Web/nonAdminStorageState.json new file mode 100644 index 000000000..f4ec35503 --- /dev/null +++ b/UI/Web/nonAdminStorageState.json @@ -0,0 +1,4 @@ +{ + "cookies": [], + "origins": [] +} \ No newline at end of file diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index c0b0ce2cd..a5b7e35b3 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -1599,6 +1599,17 @@ "@babel/helper-plugin-utils": "^7.16.7" } }, + "@babel/plugin-transform-typescript": { + "version": "7.16.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz", + "integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-typescript": "^7.16.7" + } + }, "@babel/plugin-transform-unicode-escapes": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz", @@ -1721,6 +1732,17 @@ "esutils": "^2.0.2" } }, + "@babel/preset-typescript": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz", + "integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/helper-validator-option": "^7.16.7", + "@babel/plugin-transform-typescript": "^7.16.7" + } + }, "@babel/runtime": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz", @@ -2553,6 +2575,278 @@ "read-package-json-fast": "^2.0.1" } }, + "@playwright/test": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.20.2.tgz", + "integrity": "sha512-unkLa+xe/lP7MVC0qpgadc9iSG1+LEyGBzlXhGS/vLGAJaSFs8DNfI89hNd5shHjWfNzb34JgPVnkRKCSNo5iw==", + "dev": true, + "requires": { + "@babel/code-frame": "7.16.7", + "@babel/core": "7.16.12", + "@babel/helper-plugin-utils": "7.16.7", + "@babel/plugin-proposal-class-properties": "7.16.7", + "@babel/plugin-proposal-dynamic-import": "7.16.7", + "@babel/plugin-proposal-export-namespace-from": "7.16.7", + "@babel/plugin-proposal-logical-assignment-operators": "7.16.7", + "@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7", + "@babel/plugin-proposal-numeric-separator": "7.16.7", + "@babel/plugin-proposal-optional-chaining": "7.16.7", + "@babel/plugin-proposal-private-methods": "7.16.11", + "@babel/plugin-proposal-private-property-in-object": "7.16.7", + "@babel/plugin-syntax-async-generators": "7.8.4", + "@babel/plugin-syntax-json-strings": "7.8.3", + "@babel/plugin-syntax-object-rest-spread": "7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "7.8.3", + "@babel/plugin-transform-modules-commonjs": "7.16.8", + "@babel/preset-typescript": "7.16.7", + "colors": "1.4.0", + "commander": "8.3.0", + "debug": "4.3.3", + "expect": "27.2.5", + "jest-matcher-utils": "27.2.5", + "json5": "2.2.1", + "mime": "3.0.0", + "minimatch": "3.0.4", + "ms": "2.1.3", + "open": "8.4.0", + "pirates": "4.0.4", + "playwright-core": "1.20.2", + "rimraf": "3.0.2", + "source-map-support": "0.4.18", + "stack-utils": "2.0.5", + "yazl": "2.5.1" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.16.7" + } + }, + "@babel/core": { + "version": "7.16.12", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.16.12.tgz", + "integrity": "sha512-dK5PtG1uiN2ikk++5OzSYsitZKny4wOCD0nrO4TqnW4BVBTQ2NGS3NgilvT/TEyxTST7LNyWV/T4tXDoD3fOgg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.16.8", + "@babel/helper-compilation-targets": "^7.16.7", + "@babel/helper-module-transforms": "^7.16.7", + "@babel/helpers": "^7.16.7", + "@babel/parser": "^7.16.12", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.16.10", + "@babel/types": "^7.16.8", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.1.2", + "semver": "^6.3.0", + "source-map": "^0.5.0" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true + }, + "@babel/highlight": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", + "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true + }, + "expect": { + "version": "27.2.5", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.2.5.tgz", + "integrity": "sha512-ZrO0w7bo8BgGoP/bLz+HDCI+0Hfei9jUSZs5yI/Wyn9VkG9w8oJ7rHRgYj+MA7yqqFa0IwHA3flJzZtYugShJA==", + "dev": true, + "requires": { + "@jest/types": "^27.2.5", + "ansi-styles": "^5.0.0", + "jest-get-type": "^27.0.6", + "jest-matcher-utils": "^27.2.5", + "jest-message-util": "^27.2.5", + "jest-regex-util": "^27.0.6" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "jest-matcher-utils": { + "version": "27.2.5", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.2.5.tgz", + "integrity": "sha512-qNR/kh6bz0Dyv3m68Ck2g1fLW5KlSOUNcFQh87VXHZwWc/gY6XwnKofx76Qytz3x5LDWT09/2+yXndTkaG4aWg==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^27.2.5", + "jest-get-type": "^27.0.6", + "pretty-format": "^27.2.5" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "pirates": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.4.tgz", + "integrity": "sha512-ZIrVPH+A52Dw84R0L3/VS9Op04PuQ2SEoJL6bkshmiTic/HldyW9Tf7oH5mhJZBK7NmDx27vSMrYEXPXclpDKw==", + "dev": true + }, + "playwright-core": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.20.2.tgz", + "integrity": "sha512-iV6+HftSPalynkq0CYJala1vaTOq7+gU9BRfKCdM9bAxNq/lFLrwbluug2Wt5OoUwbMABcnTThIEm3/qUhCdJQ==", + "dev": true, + "requires": { + "colors": "1.4.0", + "commander": "8.3.0", + "debug": "4.3.3", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.0", + "jpeg-js": "0.4.3", + "mime": "3.0.0", + "pixelmatch": "5.2.1", + "pngjs": "6.0.0", + "progress": "2.0.3", + "proper-lockfile": "4.1.2", + "proxy-from-env": "1.1.0", + "rimraf": "3.0.2", + "socks-proxy-agent": "6.1.1", + "stack-utils": "2.0.5", + "ws": "8.4.2", + "yauzl": "2.10.0", + "yazl": "2.5.1" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "requires": { + "source-map": "^0.5.6" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "ws": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz", + "integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==", + "dev": true + } + } + }, "@polka/url": { "version": "1.0.0-next.21", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", @@ -2919,6 +3213,16 @@ "integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==", "dev": true }, + "@types/yauzl": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz", + "integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==", + "dev": true, + "optional": true, + "requires": { + "@types/node": "*" + } + }, "@webassemblyjs/ast": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", @@ -3426,9 +3730,9 @@ "dev": true }, "async": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", - "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", "dev": true, "requires": { "lodash": "^4.17.14" @@ -3906,6 +4210,12 @@ "ieee754": "^1.1.13" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4237,6 +4547,12 @@ "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==", "dev": true }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true + }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5113,6 +5429,15 @@ } } }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, "enhanced-resolve": { "version": "5.9.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.0.tgz", @@ -5558,6 +5883,29 @@ "tmp": "^0.0.33" } }, + "extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "requires": { + "@types/yauzl": "^2.9.1", + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + } + } + }, "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", @@ -5639,6 +5987,15 @@ "bser": "2.1.1" } }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=", + "dev": true, + "requires": { + "pend": "~1.2.0" + } + }, "fetch-cookie": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", @@ -8301,6 +8658,12 @@ } } }, + "jpeg-js": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.3.tgz", + "integrity": "sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q==", + "dev": true + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9897,6 +10260,12 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "dev": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -9955,6 +10324,23 @@ "nice-napi": "^1.0.2" } }, + "pixelmatch": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.2.1.tgz", + "integrity": "sha512-WjcAdYSnKrrdDdqTcVEY7aB7UhhwjYQKYhHiBXdJef0MOaQeYpUdQ+iVyBLa5YBKS8MPVPPMX7rpOByISLpeEQ==", + "dev": true, + "requires": { + "pngjs": "^4.0.1" + }, + "dependencies": { + "pngjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-4.0.1.tgz", + "integrity": "sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==", + "dev": true + } + } + }, "pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -9964,6 +10350,86 @@ "find-up": "^4.0.0" } }, + "playwright": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.20.2.tgz", + "integrity": "sha512-p6GE8A/f2G7t8FIk/AwQ94nT7R7tyPRJyKt1FwRjwBDf4WdpgoAr4hDfMgHy+CkClR22adFjopGwhxXAPsewhg==", + "dev": true, + "requires": { + "playwright-core": "1.20.2" + }, + "dependencies": { + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true + }, + "playwright-core": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.20.2.tgz", + "integrity": "sha512-iV6+HftSPalynkq0CYJala1vaTOq7+gU9BRfKCdM9bAxNq/lFLrwbluug2Wt5OoUwbMABcnTThIEm3/qUhCdJQ==", + "dev": true, + "requires": { + "colors": "1.4.0", + "commander": "8.3.0", + "debug": "4.3.3", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.0", + "jpeg-js": "0.4.3", + "mime": "3.0.0", + "pixelmatch": "5.2.1", + "pngjs": "6.0.0", + "progress": "2.0.3", + "proper-lockfile": "4.1.2", + "proxy-from-env": "1.1.0", + "rimraf": "3.0.2", + "socks-proxy-agent": "6.1.1", + "stack-utils": "2.0.5", + "ws": "8.4.2", + "yauzl": "2.10.0", + "yazl": "2.5.1" + } + }, + "ws": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz", + "integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==", + "dev": true + } + } + }, + "pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "dev": true + }, "portfinder": { "version": "1.0.28", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz", @@ -10366,6 +10832,12 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -10400,6 +10872,25 @@ "sisteransi": "^1.0.5" } }, + "proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + }, + "dependencies": { + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=", + "dev": true + } + } + }, "protractor": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/protractor/-/protractor-7.0.0.tgz", @@ -10761,6 +11252,12 @@ } } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -10773,6 +11270,16 @@ "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -12953,6 +13460,25 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz", "integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA==" }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "requires": { + "buffer-crc32": "~0.2.3" + } + }, "yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index 235cbd85f..8d1020079 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -49,12 +49,14 @@ "@angular-devkit/build-angular": "~13.2.3", "@angular/cli": "^13.2.3", "@angular/compiler-cli": "~13.2.2", + "@playwright/test": "^1.20.2", "@types/jest": "^27.4.0", "@types/node": "^17.0.17", "codelyzer": "^6.0.2", "jest": "^27.5.1", "jest-preset-angular": "^11.1.0", "karma-coverage": "~2.2.0", + "playwright": "^1.20.2", "protractor": "~7.0.0", "ts-node": "~10.5.0", "tslint": "^6.1.3", diff --git a/UI/Web/playwright.config.ts b/UI/Web/playwright.config.ts new file mode 100644 index 000000000..8c5e0ca57 --- /dev/null +++ b/UI/Web/playwright.config.ts @@ -0,0 +1,106 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './e2e', + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000 + }, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:4200', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + globalSetup: require.resolve('./global-setup'), + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // }, + // }, + + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { + // ...devices['Pixel 5'], + // }, + // }, + // { + // name: 'Mobile Safari', + // use: { + // ...devices['iPhone 12'], + // }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { + // channel: 'msedge', + // }, + // }, + // { + // name: 'Google Chrome', + // use: { + // channel: 'chrome', + // }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // port: 3000, + // }, +}; + +export default config; diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index 8c3cdca61..d01eda14a 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -100,12 +100,12 @@ export class ErrorInterceptor implements HttpInterceptor { const err = error.error; if (err.hasOwnProperty('message') && err.message.trim() !== '') { if (err.message != 'User is not authenticated') { - console.log('500 error: ', error); + console.error('500 error: ', error); } this.toastr.error(err.message); } else if (error.hasOwnProperty('message') && error.message.trim() !== '') { if (error.message != 'User is not authenticated') { - console.log('500 error: ', error); + console.error('500 error: ', error); } this.toastr.error(error.message); } diff --git a/UI/Web/src/app/_models/book-page-layout-mode.ts b/UI/Web/src/app/_models/book-page-layout-mode.ts new file mode 100644 index 000000000..aac6c3fdb --- /dev/null +++ b/UI/Web/src/app/_models/book-page-layout-mode.ts @@ -0,0 +1,5 @@ +export enum BookPageLayoutMode { + Default = 0, + Column1 = 1, + Column2 = 2, +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/site-theme-progress-event.ts b/UI/Web/src/app/_models/events/site-theme-progress-event.ts deleted file mode 100644 index 23fab2939..000000000 --- a/UI/Web/src/app/_models/events/site-theme-progress-event.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface SiteThemeProgressEvent { - themeName: string; -} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/theme-progress-event.ts b/UI/Web/src/app/_models/events/theme-progress-event.ts new file mode 100644 index 000000000..84120e32a --- /dev/null +++ b/UI/Web/src/app/_models/events/theme-progress-event.ts @@ -0,0 +1,3 @@ +export interface ThemeProgressEvent { + themeName: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/user-progress-update-event.ts b/UI/Web/src/app/_models/events/user-progress-update-event.ts new file mode 100644 index 000000000..e36199c60 --- /dev/null +++ b/UI/Web/src/app/_models/events/user-progress-update-event.ts @@ -0,0 +1,10 @@ +export interface UserProgressUpdateEvent { + userId: number; + username: string; + //entityId: number; + //entityType: 'series' | 'collection' | 'chapter' | 'volume' | 'readingList'; + seriesId: number; + volumeId: number; + chapterId: number; + pagesRead: number; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/manga-file.ts b/UI/Web/src/app/_models/manga-file.ts index 159656807..5ae56ae2b 100644 --- a/UI/Web/src/app/_models/manga-file.ts +++ b/UI/Web/src/app/_models/manga-file.ts @@ -1,6 +1,7 @@ import { MangaFormat } from './manga-format'; export interface MangaFile { + id: number; filePath: string; pages: number; format: MangaFormat; diff --git a/UI/Web/src/app/_models/manga-reader/bookmark-info.ts b/UI/Web/src/app/_models/manga-reader/bookmark-info.ts new file mode 100644 index 000000000..e63c31390 --- /dev/null +++ b/UI/Web/src/app/_models/manga-reader/bookmark-info.ts @@ -0,0 +1,11 @@ +import { LibraryType } from "../library"; +import { MangaFormat } from "../manga-format"; + +export interface BookmarkInfo { + seriesName: string; + seriesFormat: MangaFormat; + seriesId: number; + libraryId: number; + libraryType: LibraryType; + pages: number; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/metadata/publication-status.ts b/UI/Web/src/app/_models/metadata/publication-status.ts index 7881da383..386010568 100644 --- a/UI/Web/src/app/_models/metadata/publication-status.ts +++ b/UI/Web/src/app/_models/metadata/publication-status.ts @@ -1,5 +1,7 @@ export enum PublicationStatus { OnGoing = 0, Hiatus = 1, - Completed = 2 + Completed = 2, + Cancelled = 3, + Ended = 4 } \ No newline at end of file diff --git a/UI/Web/src/app/_models/preferences/book-theme.ts b/UI/Web/src/app/_models/preferences/book-theme.ts new file mode 100644 index 000000000..4b487fb12 --- /dev/null +++ b/UI/Web/src/app/_models/preferences/book-theme.ts @@ -0,0 +1,26 @@ +import { ThemeProvider } from "./site-theme"; + +/** + * Theme for the the book reader contents + */ + export interface BookTheme { + name: string; + provider: ThemeProvider; + /** + * Main color (usually background color) that represents the theme + */ + colorHash: string; + isDefault: boolean; + /** + * Is this theme providing dark mode to the reader aka Should we style the reader controls to be dark mode + */ + isDarkTheme: boolean; + /** + * Used to identify the theme on style tag + */ + selector: string; + /** + * Inner HTML + */ + content: string; + } diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 7fbdaf185..874dd09a9 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,5 +1,6 @@ import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; +import { BookPageLayoutMode } from '../book-page-layout-mode'; import { PageSplitOption } from './page-split-option'; import { ReaderMode } from './reader-mode'; import { ReadingDirection } from './reading-direction'; @@ -16,15 +17,17 @@ export interface Preferences { layoutMode: LayoutMode; backgroundColor: string; showScreenHints: boolean; - + // Book Reader - bookReaderDarkMode: boolean; bookReaderMargin: number; bookReaderLineSpacing: number; bookReaderFontSize: number; bookReaderFontFamily: string; bookReaderTapToPaginate: boolean; bookReaderReadingDirection: ReadingDirection; + bookReaderThemeName: string; + bookReaderLayoutMode: BookPageLayoutMode; + bookReaderImmersiveMode: boolean; // Global theme: SiteTheme; @@ -35,3 +38,4 @@ export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automati 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}]; +export const bookLayoutModes = [{text: 'Default', value: BookPageLayoutMode.Default}, {text: '1 Column', value: BookPageLayoutMode.Column1}, {text: '2 Column', value: BookPageLayoutMode.Column2}]; diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index ad1a325b8..da7932acc 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -19,5 +19,6 @@ export interface ReadingList { title: string; summary: string; promoted: boolean; + coverImageLocked: boolean; items: Array; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/search/search-result-group.ts b/UI/Web/src/app/_models/search/search-result-group.ts index 377593669..901e63548 100644 --- a/UI/Web/src/app/_models/search/search-result-group.ts +++ b/UI/Web/src/app/_models/search/search-result-group.ts @@ -1,4 +1,6 @@ +import { Chapter } from "../chapter"; import { Library } from "../library"; +import { MangaFile } from "../manga-file"; import { SearchResult } from "../search-result"; import { Tag } from "../tag"; @@ -10,6 +12,8 @@ export class SearchResultGroup { persons: Array = []; genres: Array = []; tags: Array = []; + files: Array = []; + chapters: Array = []; reset() { this.libraries = []; @@ -19,5 +23,7 @@ export class SearchResultGroup { this.persons = []; this.genres = []; this.tags = []; + this.files = []; + this.chapters = []; } } \ No newline at end of file diff --git a/UI/Web/src/app/_models/series-detail/related-series.ts b/UI/Web/src/app/_models/series-detail/related-series.ts new file mode 100644 index 000000000..02d394382 --- /dev/null +++ b/UI/Web/src/app/_models/series-detail/related-series.ts @@ -0,0 +1,17 @@ +import { Series } from "../series"; + +export interface RelatedSeries { + sourceSeriesId: number; + sequels: Array; + prequels: Array; + spinOffs: Array; + adaptations: Array; + sideStories: Array; + characters: Array; + contains: Array; + others: Array; + alternativeSettings: Array; + alternativeVersions: Array; + doujinshis: Array; + parent: Array; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/series-detail/relation-kind.ts b/UI/Web/src/app/_models/series-detail/relation-kind.ts new file mode 100644 index 000000000..203401af3 --- /dev/null +++ b/UI/Web/src/app/_models/series-detail/relation-kind.ts @@ -0,0 +1,31 @@ +export enum RelationKind { + Prequel = 1, + Sequel = 2, + SpinOff = 3, + Adaptation = 4, + SideStory = 5, + Character = 6, + Contains = 7, + Other = 8, + AlternativeSetting = 9, + AlternativeVersion = 10, + Doujinshi = 11, + /** + * This is UI only. Backend will generate Parent series for everything but Prequel/Sequel + */ + Parent = 12 +} + +export const RelationKinds = [ + {text: 'Prequel', value: RelationKind.Prequel}, + {text: 'Sequel', value: RelationKind.Sequel}, + {text: 'Spin Off', value: RelationKind.SpinOff}, + {text: 'Adaptation', value: RelationKind.Adaptation}, + {text: 'Alternative Setting', value: RelationKind.AlternativeSetting}, + {text: 'Alternative Version', value: RelationKind.AlternativeVersion}, + {text: 'Side Story', value: RelationKind.SideStory}, + {text: 'Character', value: RelationKind.Character}, + {text: 'Contains', value: RelationKind.Contains}, + {text: 'Doujinshi', value: RelationKind.Doujinshi}, + {text: 'Other', value: RelationKind.Other}, +]; \ No newline at end of file diff --git a/UI/Web/src/app/_models/series-filter.ts b/UI/Web/src/app/_models/series-filter.ts index aeafb3331..c2b823ce3 100644 --- a/UI/Web/src/app/_models/series-filter.ts +++ b/UI/Web/src/app/_models/series-filter.ts @@ -40,7 +40,8 @@ export interface SortOptions { export enum SortField { SortName = 1, Created = 2, - LastModified = 3 + LastModified = 3, + LastChapterAdded = 4 } export interface ReadStatus { diff --git a/UI/Web/src/app/_models/series-metadata.ts b/UI/Web/src/app/_models/series-metadata.ts index 9905cc1a6..d15dcc2f8 100644 --- a/UI/Web/src/app/_models/series-metadata.ts +++ b/UI/Web/src/app/_models/series-metadata.ts @@ -8,8 +8,11 @@ import { Tag } from "./tag"; export interface SeriesMetadata { seriesId: number; summary: string; - collectionTags: Array; + totalCount: number; + maxCount: number; + + collectionTags: Array; genres: Array; tags: Array; writers: Array; diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index 0e3123ac3..17b489b6e 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -44,4 +44,8 @@ export interface Series { * DateTime that represents last time the logged in user read this series */ latestReadDate: string; + /** + * DateTime representing last time a chapter was added to the Series + */ + lastChapterAdded: string; } diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 3057704cb..6238c7b6a 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -7,7 +7,7 @@ import { Preferences } from '../_models/preferences/preferences'; import { User } from '../_models/user'; import { Router } from '@angular/router'; import { MessageHubService } from './message-hub.service'; -import { ThemeService } from '../theme.service'; +import { ThemeService } from './theme.service'; import { InviteUserResponse } from '../_models/invite-user-response'; @Injectable({ @@ -235,9 +235,7 @@ export class AccountService implements OnDestroy { // set a timeout to refresh the token a minute before it expires const expires = new Date(jwtToken.exp * 1000); const timeout = expires.getTime() - Date.now() - (60 * 1000); - this.refreshTokenTimeout = setTimeout(() => this.refreshToken().subscribe(() => { - console.log('Token Refreshed'); - }), timeout); + this.refreshTokenTimeout = setTimeout(() => this.refreshToken().subscribe(() => {}), timeout); } private stopRefreshTokenTimer() { diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 6f47bd178..b67a162bd 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -17,10 +17,21 @@ export enum Action { Info = 5, RefreshMetadata = 6, Download = 7, + /** + * @deprecated This is no longer supported. Use the dedicated page instead + */ Bookmarks = 8, IncognitoRead = 9, AddToReadingList = 10, - AddToCollection = 11 + AddToCollection = 11, + /** + * Essentially a download, but handled differently. Needed so card bubbles it up for handling + */ + DownloadBookmark = 12, + /** + * Open Series detail page for said series + */ + ViewSeries = 13 } export interface ActionItem { @@ -47,6 +58,8 @@ export class ActionFactoryService { readingListActions: Array> = []; + bookmarkActions: Array> = []; + isAdmin = false; hasDownloadRole = false; @@ -181,6 +194,12 @@ export class ActionFactoryService { return actions; } + getBookmarkActions(callback: (action: Action, series: Series) => void) { + const actions = this.bookmarkActions.map(a => {return {...a}}); + actions.forEach(action => action.callback = callback); + return actions; + } + filterBookmarksForFormat(action: ActionItem, series: Series) { if (action.action === Action.Bookmarks && series?.format === MangaFormat.EPUB) return false; return true; @@ -206,12 +225,6 @@ export class ActionFactoryService { callback: this.dummyCallback, requiresAdmin: false }, - { - action: Action.Bookmarks, - title: 'Bookmarks', - callback: this.dummyCallback, - requiresAdmin: false - }, { action: Action.AddToReadingList, title: 'Add to Reading List', @@ -241,7 +254,7 @@ export class ActionFactoryService { }, { action: Action.IncognitoRead, - title: 'Read in Incognito', + title: 'Read Incognito', callback: this.dummyCallback, requiresAdmin: false }, @@ -268,7 +281,7 @@ export class ActionFactoryService { }, { action: Action.IncognitoRead, - title: 'Read in Incognito', + title: 'Read Incognito', callback: this.dummyCallback, requiresAdmin: false }, @@ -294,5 +307,26 @@ export class ActionFactoryService { requiresAdmin: false }, ]; + + this.bookmarkActions = [ + { + action: Action.ViewSeries, + title: 'View Series', + callback: this.dummyCallback, + requiresAdmin: false + }, + { + action: Action.DownloadBookmark, + title: 'Download', + callback: this.dummyCallback, + requiresAdmin: false + }, + { + action: Action.Delete, + title: 'Clear', + callback: this.dummyCallback, + requiresAdmin: false + }, + ] } } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index b6fc00704..7f05a02d8 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -3,7 +3,6 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { Subject } from 'rxjs'; import { take } from 'rxjs/operators'; -import { BookmarksModalComponent } from '../cards/_modals/bookmarks-modal/bookmarks-modal.component'; import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; @@ -302,25 +301,6 @@ export class ActionService implements OnDestroy { }); } - - openBookmarkModal(series: Series, callback?: SeriesActionCallback) { - if (this.bookmarkModalRef != null) { return; } - this.bookmarkModalRef = this.modalService.open(BookmarksModalComponent, { scrollable: true, size: 'lg' }); - this.bookmarkModalRef.componentInstance.series = series; - this.bookmarkModalRef.closed.pipe(take(1)).subscribe(() => { - this.bookmarkModalRef = null; - if (callback) { - callback(series); - } - }); - this.bookmarkModalRef.dismissed.pipe(take(1)).subscribe(() => { - this.bookmarkModalRef = null; - if (callback) { - callback(series); - } - }); - } - addMultipleToReadingList(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); @@ -460,7 +440,7 @@ export class ActionService implements OnDestroy { } editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) { - const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'md' }); + const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'lg' }); readingListModalRef.componentInstance.readingList = readingList; readingListModalRef.closed.pipe(take(1)).subscribe((list) => { if (callback && list !== undefined) { diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index d0abe8c12..a6ab89927 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -1,12 +1,10 @@ -import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable, OnDestroy } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; -import { ThemeService } from '../theme.service'; +import { ThemeService } from './theme.service'; import { RecentlyAddedItem } from '../_models/recently-added-item'; import { AccountService } from './account.service'; -import { NavService } from './nav.service'; @Injectable({ providedIn: 'root' @@ -75,6 +73,10 @@ export class ImageService implements OnDestroy { return this.baseUrl + 'image/collection-cover?collectionTagId=' + collectionTagId; } + getReadingListCoverImage(readingListId: number) { + return this.baseUrl + 'image/readinglist-cover?readingListId=' + readingListId; + } + getChapterCoverImage(chapterId: number) { return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId; } diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 7aea516f0..697de7b1c 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -4,7 +4,6 @@ import { of } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Library, LibraryType } from '../_models/library'; -import { SearchResult } from '../_models/search-result'; import { SearchResultGroup } from '../_models/search/search-result-group'; diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index f53b9681f..852d8a906 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { environment } from 'src/environments/environment'; import { LibraryModifiedEvent } from '../_models/events/library-modified-event'; import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; -import { SiteThemeProgressEvent } from '../_models/events/site-theme-progress-event'; +import { ThemeProgressEvent } from '../_models/events/theme-progress-event'; import { User } from '../_models/user'; export enum EVENTS { @@ -53,7 +53,11 @@ export enum EVENTS { /** * A library is created or removed from the instance */ - LibraryModified = 'LibraryModified' + LibraryModified = 'LibraryModified', + /** + * A user updates an entities read progress + */ + UserProgressUpdate = 'UserProgressUpdate', } export interface Message { @@ -153,7 +157,7 @@ export class MessageHubService { this.hubConnection.on(EVENTS.SiteThemeProgress, resp => { this.messagesSource.next({ event: EVENTS.SiteThemeProgress, - payload: resp.body as SiteThemeProgressEvent + payload: resp.body as ThemeProgressEvent }); }); @@ -164,6 +168,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.UserProgressUpdate, resp => { + this.messagesSource.next({ + event: EVENTS.UserProgressUpdate, + payload: resp.body + }); + }); + this.hubConnection.on(EVENTS.Error, resp => { this.messagesSource.next({ event: EVENTS.Error, diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 878dba34b..f99410894 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,17 +1,15 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable, of } from 'rxjs'; +import { of } from 'rxjs'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { UtilityService } from '../shared/_services/utility.service'; -import { TypeaheadSettings } from '../typeahead/typeahead-settings'; -import { ChapterMetadata } from '../_models/chapter-metadata'; import { Genre } from '../_models/genre'; import { AgeRating } from '../_models/metadata/age-rating'; import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; import { Language } from '../_models/metadata/language'; import { PublicationStatusDto } from '../_models/metadata/publication-status-dto'; -import { Person, PersonRole } from '../_models/person'; +import { Person } from '../_models/person'; import { Tag } from '../_models/tag'; @Injectable({ diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index a30e963e6..db1c6b048 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -41,6 +41,8 @@ export class NavService { */ showNavBar() { this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '56px'); + this.renderer.setStyle(this.document.querySelector('body'), 'height', 'calc(var(--vh)*100 - 56px)'); + this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - 56px)'); this.navbarVisibleSource.next(true); } @@ -49,6 +51,8 @@ export class NavService { */ hideNavBar() { this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '0px'); + this.renderer.removeStyle(this.document.querySelector('body'), 'height'); + this.renderer.removeStyle(this.document.querySelector('html'), 'height'); this.navbarVisibleSource.next(false); } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 33a788a88..b41efcb93 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -4,6 +4,7 @@ import { environment } from 'src/environments/environment'; import { ChapterInfo } from '../manga-reader/_models/chapter-info'; import { UtilityService } from '../shared/_services/utility.service'; import { Chapter } from '../_models/chapter'; +import { BookmarkInfo } from '../_models/manga-reader/bookmark-info'; import { PageBookmark } from '../_models/page-bookmark'; import { ProgressBookmark } from '../_models/progress-bookmark'; import { Volume } from '../_models/volume'; @@ -48,6 +49,14 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/remove-bookmarks', {seriesId}); } + /** + * Used exclusively for reading multiple bookmarks from a series + * @param seriesId + */ + getBookmarkInfo(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'reader/bookmark-info?seriesId=' + seriesId); + } + getProgress(chapterId: number) { return this.httpClient.get(this.baseUrl + 'reader/get-progress?chapterId=' + chapterId); } @@ -56,6 +65,10 @@ export class ReaderService { return this.baseUrl + 'reader/image?chapterId=' + chapterId + '&page=' + page; } + getBookmarkPageUrl(seriesId: number, apiKey: string, page: number) { + return this.baseUrl + 'reader/bookmark-image?seriesId=' + seriesId + '&page=' + page + '&apiKey=' + encodeURIComponent(apiKey); + } + getChapterInfo(chapterId: number) { return this.httpClient.get(this.baseUrl + 'reader/chapter-info?chapterId=' + chapterId); } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index e2c5c6104..dae0708b6 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; +import { UtilityService } from '../shared/_services/utility.service'; import { PaginatedResult } from '../_models/pagination'; import { ReadingList, ReadingListItem } from '../_models/reading-list'; import { ActionItem } from './action-factory.service'; @@ -13,7 +14,7 @@ export class ReadingListService { baseUrl = environment.apiUrl; - constructor(private httpClient: HttpClient) { } + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } getReadingList(readingListId: number) { return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); @@ -21,11 +22,11 @@ export class ReadingListService { getReadingLists(includePromoted: boolean = true, pageNum?: number, itemsPerPage?: number) { let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); return this.httpClient.post>(this.baseUrl + 'readinglist/lists?includePromoted=' + includePromoted, {}, {observe: 'response', params}).pipe( map((response: any) => { - return this._cachePaginatedResults(response, new PaginatedResult()); + return this.utilityService.createPaginatedResult(response, new PaginatedResult()); }) ); } @@ -86,29 +87,4 @@ export class ReadingListService { if (readingList?.promoted && !isAdmin) return false; return true; } - - _addPaginationIfExists(params: HttpParams, pageNum?: number, itemsPerPage?: number) { - // TODO: Move to utility service - if (pageNum !== null && pageNum !== undefined && itemsPerPage !== null && itemsPerPage !== undefined) { - params = params.append('pageNumber', pageNum + ''); - params = params.append('pageSize', itemsPerPage + ''); - } - return params; - } - - _cachePaginatedResults(response: any, paginatedVariable: PaginatedResult) { - // TODO: Move to utility service - if (response.body === null) { - paginatedVariable.result = []; - } else { - paginatedVariable.result = response.body; - } - - const pageHeader = response.headers.get('Pagination'); - if (pageHeader !== null) { - paginatedVariable.pagination = JSON.parse(pageHeader); - } - - return paginatedVariable; - } } diff --git a/UI/Web/src/app/_services/recommendation.service.ts b/UI/Web/src/app/_services/recommendation.service.ts new file mode 100644 index 000000000..ae3360ec3 --- /dev/null +++ b/UI/Web/src/app/_services/recommendation.service.ts @@ -0,0 +1,45 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { map } from 'rxjs'; +import { environment } from 'src/environments/environment'; +import { UtilityService } from '../shared/_services/utility.service'; +import { PaginatedResult } from '../_models/pagination'; +import { Series } from '../_models/series'; + +@Injectable({ + providedIn: 'root' +}) +export class RecommendationService { + + private baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } + + getQuickReads(libraryId: number, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + return this.httpClient.get>(this.baseUrl + 'recommended/quick-reads?libraryId=' + libraryId, {observe: 'response', params}) + .pipe(map(response => this.utilityService.createPaginatedResult(response))); + } + + getHighlyRated(libraryId: number, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + return this.httpClient.get>(this.baseUrl + 'recommended/highly-rated?libraryId=' + libraryId, {observe: 'response', params}) + .pipe(map(response => this.utilityService.createPaginatedResult(response))); + } + + getRediscover(libraryId: number, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + return this.httpClient.get>(this.baseUrl + 'recommended/rediscover?libraryId=' + libraryId, {observe: 'response', params}) + .pipe(map(response => this.utilityService.createPaginatedResult(response))); + } + + getMoreIn(libraryId: number, genreId: number, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + return this.httpClient.get>(this.baseUrl + 'recommended/more-in?libraryId=' + libraryId + '&genreId=' + genreId, {observe: 'response', params}) + .pipe(map(response => this.utilityService.createPaginatedResult(response))); + } +} diff --git a/UI/Web/src/app/scroll.service.ts b/UI/Web/src/app/_services/scroll.service.ts similarity index 75% rename from UI/Web/src/app/scroll.service.ts rename to UI/Web/src/app/_services/scroll.service.ts index 15e00b89d..7c4b07ea2 100644 --- a/UI/Web/src/app/scroll.service.ts +++ b/UI/Web/src/app/_services/scroll.service.ts @@ -18,5 +18,12 @@ export class ScrollService { top: top, behavior: 'smooth' }); - } + } + + scrollToX(left: number, el: Element | Window = window) { + el.scroll({ + left: left, + behavior: 'auto' + }); + } } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index e6c8e1cf2..e62ec97a9 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -3,12 +3,13 @@ import { Injectable } from '@angular/core'; import { of } from 'rxjs'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; +import { UtilityService } from '../shared/_services/utility.service'; import { Chapter } from '../_models/chapter'; import { ChapterMetadata } from '../_models/chapter-metadata'; import { CollectionTag } from '../_models/collection-tag'; import { PaginatedResult } from '../_models/pagination'; -import { RecentlyAddedItem } from '../_models/recently-added-item'; import { Series } from '../_models/series'; +import { RelatedSeries } from '../_models/series-detail/related-series'; import { SeriesDetail } from '../_models/series-detail/series-detail'; import { SeriesFilter } from '../_models/series-filter'; import { SeriesGroup } from '../_models/series-group'; @@ -25,43 +26,28 @@ export class SeriesService { paginatedResults: PaginatedResult = new PaginatedResult(); paginatedSeriesForTagsResults: PaginatedResult = new PaginatedResult(); - constructor(private httpClient: HttpClient, private imageService: ImageService) { } - - _cachePaginatedResults(response: any, paginatedVariable: PaginatedResult) { - if (response.body === null) { - paginatedVariable.result = []; - } else { - paginatedVariable.result = response.body; - } - - const pageHeader = response.headers.get('Pagination'); - if (pageHeader !== null) { - paginatedVariable.pagination = JSON.parse(pageHeader); - } - - return paginatedVariable; - } + constructor(private httpClient: HttpClient, private imageService: ImageService, private utilityService: UtilityService) { } getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = this.createSeriesFilter(filter); return this.httpClient.post>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe( map((response: any) => { - return this._cachePaginatedResults(response, this.paginatedResults); + return this.utilityService.createPaginatedResult(response, this.paginatedResults); }) ); } getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = this.createSeriesFilter(filter); return this.httpClient.post>(this.baseUrl + 'series?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map((response: any) => { - return this._cachePaginatedResults(response, this.paginatedResults); + return this.utilityService.createPaginatedResult(response, this.paginatedResults); }) ); } @@ -90,8 +76,12 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId); } - getData(id: number) { - return of(id); + getSeriesForMangaFile(mangaFileId: number) { + return this.httpClient.get(this.baseUrl + 'series/series-for-mangafile?mangaFileId=' + mangaFileId); + } + + getSeriesForChapter(chapterId: number) { + return this.httpClient.get(this.baseUrl + 'series/series-for-chapter?chapterId=' + chapterId); } delete(seriesId: number) { @@ -121,11 +111,11 @@ export class SeriesService { getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { const data = this.createSeriesFilter(filter); let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); return this.httpClient.post(this.baseUrl + 'series/recently-added?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map(response => { - return this._cachePaginatedResults(response, new PaginatedResult()); + return this.utilityService.createPaginatedResult(response, new PaginatedResult()); }) ); } @@ -133,19 +123,16 @@ export class SeriesService { getRecentlyUpdatedSeries() { return this.httpClient.post(this.baseUrl + 'series/recently-updated-series', {}); } - getRecentlyAddedChapters() { - return this.httpClient.post(this.baseUrl + 'series/recently-added-chapters', {}); - } getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { const data = this.createSeriesFilter(filter); let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); return this.httpClient.post(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map(response => { - return this._cachePaginatedResults(response, new PaginatedResult()); + return this.utilityService.createPaginatedResult(response, new PaginatedResult()); })); } @@ -176,28 +163,36 @@ export class SeriesService { getSeriesForTag(collectionTagId: number, pageNum?: number, itemsPerPage?: number) { let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); return this.httpClient.get>(this.baseUrl + 'series/series-by-collection?collectionId=' + collectionTagId, {observe: 'response', params}).pipe( map((response: any) => { - return this._cachePaginatedResults(response, this.paginatedSeriesForTagsResults); + return this.utilityService.createPaginatedResult(response, this.paginatedSeriesForTagsResults); }) ); } + getRelatedForSeries(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'series/all-related?seriesId=' + seriesId); + } + + updateRelationships(seriesId: number, adaptations: Array, characters: Array, + contains: Array, others: Array, prequels: Array, + sequels: Array, sideStories: Array, spinOffs: Array, + alternativeSettings: Array, alternativeVersions: Array, doujinshis: Array) { + return this.httpClient.post(this.baseUrl + 'series/update-related?seriesId=' + seriesId, + {seriesId, adaptations, characters, sequels, prequels, contains, others, sideStories, spinOffs, + alternativeSettings, alternativeVersions, doujinshis}); + } + getSeriesDetail(seriesId: number) { return this.httpClient.get(this.baseUrl + 'series/series-detail?seriesId=' + seriesId); } - _addPaginationIfExists(params: HttpParams, pageNum?: number, itemsPerPage?: number) { - if (pageNum !== null && pageNum !== undefined && itemsPerPage !== null && itemsPerPage !== undefined) { - params = params.append('pageNumber', pageNum + ''); - params = params.append('pageSize', itemsPerPage + ''); - } - return params; - } + createSeriesFilter(filter?: SeriesFilter) { + if (filter !== undefined) return filter; const data: SeriesFilter = { formats: [], libraries: [], @@ -228,8 +223,6 @@ export class SeriesService { seriesNameQuery: '', }; - if (filter === undefined) return data; - - return filter; + return data; } } diff --git a/UI/Web/src/app/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts similarity index 75% rename from UI/Web/src/app/theme.service.ts rename to UI/Web/src/app/_services/theme.service.ts index bf6c33ba0..23dc8e90c 100644 --- a/UI/Web/src/app/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -4,12 +4,10 @@ import { Inject, Injectable, OnDestroy, Renderer2, RendererFactory2, SecurityCon import { DomSanitizer } from '@angular/platform-browser'; import { map, ReplaySubject, Subject, takeUntil } from 'rxjs'; import { environment } from 'src/environments/environment'; -import { ConfirmService } from './shared/confirm.service'; -import { NotificationProgressEvent } from './_models/events/notification-progress-event'; -import { SiteThemeProgressEvent } from './_models/events/site-theme-progress-event'; -import { SiteTheme, ThemeProvider } from './_models/preferences/site-theme'; -import { EVENTS, MessageHubService } from './_services/message-hub.service'; - +import { ConfirmService } from '../shared/confirm.service'; +import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; +import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme'; +import { EVENTS, MessageHubService } from './message-hub.service'; @Injectable({ @@ -18,6 +16,7 @@ import { EVENTS, MessageHubService } from './_services/message-hub.service'; export class ThemeService implements OnDestroy { public defaultTheme: string = 'dark'; + public defaultBookTheme: string = 'Dark'; private currentThemeSource = new ReplaySubject(1); public currentTheme$ = this.currentThemeSource.asObservable(); @@ -33,9 +32,9 @@ export class ThemeService implements OnDestroy { private readonly onDestroy = new Subject(); private renderer: Renderer2; private baseUrl = environment.apiUrl; - - constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient, + + constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient, messageHub: MessageHubService, private domSantizer: DomSanitizer, private confirmService: ConfirmService) { this.renderer = rendererFactory.createRenderer(null, null); @@ -48,7 +47,7 @@ export class ThemeService implements OnDestroy { if (notificationEvent.name !== EVENTS.SiteThemeProgress) return; if (notificationEvent.eventType === 'ended') { - this.getThemes().subscribe(() => {}); + if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe(() => {}); } }); } @@ -62,6 +61,10 @@ export class ThemeService implements OnDestroy { return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim(); } + getCssVariable(variable: string) { + return getComputedStyle(this.document.body).getPropertyValue(variable).trim(); + } + isDarkTheme() { return this.getColorScheme().toLowerCase() === 'dark'; } @@ -74,6 +77,13 @@ export class ThemeService implements OnDestroy { })); } + /** + * Used in book reader to remove all themes so book reader can provide custom theming options + */ + clearThemes() { + this.unsetThemes(); + } + setDefault(themeId: number) { return this.httpClient.post(this.baseUrl + 'theme/update-default', {themeId: themeId}).pipe(map(() => { // Refresh the cache when a default state is changed @@ -85,8 +95,25 @@ export class ThemeService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'theme/scan', {}); } + /** + * Sets the book theme on the body tag so css variable overrides can take place + * @param selector brtheme- prefixed string + */ + setBookTheme(selector: string) { + this.unsetBookThemes(); + this.renderer.addClass(this.document.querySelector('body'), selector); + } - setTheme(themeName: string) { + clearBookTheme() { + this.unsetBookThemes(); + } + + + /** + * Sets the theme as active. Will inject a style tag into document to load a custom theme and apply the selector to the body + * @param themeName + */ + setTheme(themeName: string) { const theme = this.themeCache.find(t => t.name.toLowerCase() === themeName.toLowerCase()); if (theme) { this.unsetThemes(); @@ -133,4 +160,10 @@ export class ThemeService implements OnDestroy { private unsetThemes() { this.themeCache.forEach(theme => this.document.body.classList.remove(theme.selector)); } + + private unsetBookThemes() { + Array.from(this.document.body.classList).filter(cls => cls.startsWith('brtheme-')).forEach(c => this.document.body.classList.remove(c)); + } + + } diff --git a/UI/Web/src/app/_services/toggle.service.ts b/UI/Web/src/app/_services/toggle.service.ts new file mode 100644 index 000000000..8b335394a --- /dev/null +++ b/UI/Web/src/app/_services/toggle.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { NavigationStart, Router } from '@angular/router'; +import { filter, ReplaySubject, take } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ToggleService { + + toggleState: boolean = false; + + + private toggleStateSource: ReplaySubject = new ReplaySubject(1); + public toggleState$ = this.toggleStateSource.asObservable(); + + constructor(router: Router) { + router.events + .pipe(filter(event => event instanceof NavigationStart)) + .subscribe((event) => { + this.toggleState = false; + this.toggleStateSource.next(this.toggleState); + }); + this.toggleStateSource.next(false); + } + + toggle() { + this.toggleState = !this.toggleState; + this.toggleStateSource.pipe(take(1)).subscribe(state => { + this.toggleState = !state; + this.toggleStateSource.next(this.toggleState); + }); + + } + + set(state: boolean) { + this.toggleState = state; + this.toggleStateSource.next(state); + } +} diff --git a/UI/Web/src/app/_services/upload.service.ts b/UI/Web/src/app/_services/upload.service.ts index 7d930e8e6..8f3c1d07a 100644 --- a/UI/Web/src/app/_services/upload.service.ts +++ b/UI/Web/src/app/_services/upload.service.ts @@ -30,6 +30,10 @@ export class UploadService { return this.httpClient.post(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url)}); } + updateReadingListCoverImage(readingListId: number, url: string) { + return this.httpClient.post(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url)}); + } + updateChapterCoverImage(chapterId: number, url: string) { return this.httpClient.post(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)}); } diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index ceec62cae..29b61caa5 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -1,11 +1,9 @@ import { Component, Input, OnInit } from '@angular/core'; import { FormGroup, FormControl, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { ConfirmService } from 'src/app/shared/confirm.service'; import { Library } from 'src/app/_models/library'; import { Member } from 'src/app/_models/member'; import { AccountService } from 'src/app/_services/account.service'; -import { ServerService } from 'src/app/_services/server.service'; // TODO: Rename this to EditUserModal @Component({ @@ -27,8 +25,7 @@ export class EditUserComponent implements OnInit { public get username() { return this.userForm.get('username'); } public get password() { return this.userForm.get('password'); } - constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService, - private confirmService: ConfirmService) { } + constructor(public modal: NgbActiveModal, private accountService: AccountService) { } ngOnInit(): void { this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email])); diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html index 495b3f6e9..1164627a7 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.html +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -6,7 +6,8 @@
-
- - - {{pageStyles['font-size']}} - -
-
- - - {{pageStyles['line-height']}} - -
-
- - - {{pageStyles['margin-right']}} - -
-
- - -
-
- - - -
-
- - The ability to click the sides of the page to page left and right - The ability to click the sides of the page to page left and right - -
-
- - Put reader in fullscreen mode - - - - -
-
- -
- -
- -
{{pageNum}}
-
- -
-
{{maxPages - 1}}
- -
-
-

Table of Contents

-
- This book does not have Table of Contents set in the metadata or a toc file -
-
- -
- - - -
+
  • + Table of Contents + + + +
  • + + +
    -
    +
    + + +
    +
    +
    -
    +
    -
    -
    +
    -
    - + +
    + +
    -
    - - - + +
    @@ -138,16 +114,15 @@
    - {{bookTitle}} (Incognito Mode) + {{bookTitle}}
    - +
    diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss index 1d70b604d..3eb8a3a7f 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss @@ -28,19 +28,28 @@ src: url(../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf) format("truetype"); } -$dark-form-background-no-opacity: rgb(1, 4, 9); -$primary-color: #0062cc; - -.control-container { - padding-bottom: 5px; +@font-face { + font-family: "OpenDyslexic2"; + src: url(../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf) format("opentype"); } -.table-of-contents li { - cursor: pointer; +:root { + --br-actionbar-button-text-color: #6c757d; + --accordion-body-bg-color: black; + --accordion-header-bg-color: grey; + --br-actionbar-button-hover-border-color: #6c757d; + --br-actionbar-bg-color: white; +} - &.active { - font-weight: bold; - } + +$dark-form-background-no-opacity: rgb(1, 4, 9); +$primary-color: #0062cc; +$action-bar-height: 38px; + + +// Drawer +.control-container { + padding-bottom: 5px; } .page-stub { @@ -49,123 +58,183 @@ $primary-color: #0062cc; padding-right: 2px; } +.drawer-body { + .reader-pills { + justify-content: center; + margin: 0 0.25rem; + + li a { + border: 1px solid var(--primary-color); + margin: 0 0.25rem; + + .active { + border: unset; + } + } + } +} + +// Drawer End + .fixed-top { z-index: 1022; } -.dark-mode { - - color: #dcdcdc !important; - background-image: none !important; - background-color: #292929 !important; - - *:not(code), *:not(a) { - background-color: #292929; - box-shadow: none; - text-shadow: none; - border-radius: unset; - color: #dcdcdc !important; - } - - *:not(input), *:not(code), *:not(:link) { - color: #dcdcdc !important; - } - - code { - color: #e83e8c !important; - } - - .btn-icon { - background-color: transparent; - } - - :link, a { - color: #8db2e5 !important; - } - - img, img[src] { - z-index: 1; - filter: brightness(0.85) !important; - background-color: initial !important; - } - - :visited, :visited *, :visited *[class] {color: rgb(211, 138, 138) !important} - :link:not(cite), :link *:not(cite) {color: #8db2e5 !important} +.dark-mode .overlay { + opacity: 0; } -.reading-bar { - background-color: white; +::ng-deep .bg-warning { + background-color: yellow; +} + + +.action-bar { + background-color: var(--br-actionbar-bg-color); overflow: hidden; box-shadow: 0 0 6px 0 rgb(0 0 0 / 70%); -} + max-height: $action-bar-height; + height: $action-bar-height; -.dark-mode { - .reading-bar, .book-title, .drawer-body, .drawer-container { - background-color: $dark-form-background-no-opacity; + .book-title-text { + text-align: center; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; } - button { - background-color: $dark-form-background-no-opacity; - } - - .btn { - &.btn-secondary { - border-color: transparent; - &:hover, &:focus { - border-color: #545b62; - } + @media(max-width: 875px) { + .book-title { + display: none; } - - &.btn-outline-secondary { - border-color: transparent; + } - &:hover, &:focus { - border-color: #545b62; - } - } - - span { - background-color: unset; - } - - i { - background-color: unset; - } - } -} - -::ng-deep .dark-mode .drawer-container { - .header, body, *:not(.progress-bar) { - background-color: $dark-form-background-no-opacity !important; - } -} - -@media(max-width: 875px) { .book-title { - display: none; + margin-top: 10px; + text-align: center; + text-transform: capitalize; + max-height: inherit; } - -} -.book-title { - margin-top: 10px; - text-align: center; - text-transform: capitalize; -} - -.reading-section { - max-height: 100vh; - width: 100%; - //overflow: auto; // This will break progress reporting + .next-page-highlight { + color: var(--primary-color); + } } .reader-container { outline: none; // Only the reading section itself shouldn't receive any outline. We use it to shift focus in fullscreen mode overflow: auto; + height: calc(var(--vh, 1vh) * 100); + position: relative; + + &.column-layout-1 { + height: calc(var(--vh) * 100); + } + + &.column-layout-2 { + height: calc(var(--vh) * 100); + } +} + +.reading-section { + width: 100%; + //overflow: auto; // This will break progress reporting + height: 100vh; + padding-top: $action-bar-height; + position: relative; + + &.column-layout-1 { + height: calc((var(--vh, 1vh) * 100) - $action-bar-height); + } + + &.column-layout-2 { + height: calc((var(--vh, 1vh) * 100) - $action-bar-height); + } +} + +.book-container { + position: relative; + height: 100%; + + &.column-layout-1 { + height: calc((var(--vh, 1vh) * 100) - $action-bar-height); + } + + &.column-layout-2 { + height: calc((var(--vh, 1vh) * 100) - $action-bar-height); + } } .book-content { position: relative; + padding: 20px 0; + margin: 0px 0px; + + &.column-layout-1 { + height: calc((var(--vh) * 100) - calc($action-bar-height * 2)); + } + + &.column-layout-2 { + height: calc((var(--vh) * 100) - calc($action-bar-height * 2)); + } + + a, :link { + color: var(--brtheme-link-text-color); + } + + background-color: var(--brtheme-bg-color); +} + +.pagination-cont { + background: var(--br-actionbar-bg-color); + border-radius: 5px; + padding: 5px 15px; + margin: 0 0 5px; + border: var(--drawer-pagination-border); +} + +.virt-pagination-cont { + padding-bottom: 5px; + margin-bottom: 5px; + box-shadow: var(--drawer-pagination-horizontal-rule); +} + + + +// This is essentially fitting the text to height and when you press next you are scrolling over by page width +.column-layout-1 { + .book-content { + column-count: 1; + column-gap: 20px; + overflow: hidden; + word-break: break-word; + overflow-wrap: break-word; + + &.debug { + column-rule: 20px solid rebeccapurple; + } + } + + +} + +.column-layout-2 { + .book-content { + column-count: 2; + column-gap: 20px; + overflow: hidden; + word-break: break-word; + overflow-wrap: break-word; + + &.debug { + column-rule: 20px solid rebeccapurple; + } + } + + + } // A bunch of resets so books render correctly @@ -175,18 +244,15 @@ $primary-color: #0062cc; } } -.drawer-body { - padding-bottom: 20px; -} -.chapter-title { - padding-inline-start: 0px -} - -::ng-deep .scale-width { +// This is applied to images in the backend +::ng-deep .kavita-scale-width { max-width: 100%; object-fit: contain; object-position: top center; + break-inside: avoid; + break-before: column; + max-height: 100vh; } @@ -195,48 +261,43 @@ $primary-color: #0062cc; color: $primary-color; } -.dark-mode .overlay { - opacity: 0; -} .right { - position: fixed; + position: absolute; right: 0px; // with scrollbar: 17px - top: 0px; + top: $action-bar-height; width: 20%; // with scrollbar: 18% - height: 100%; + z-index: 2; cursor: pointer; - opacity: 0; background: transparent; } // This class pushes the click area to the left a bit to let users click the scrollbar .right-with-scrollbar { - position: fixed; + position: absolute; right: 17px; - top: 0px; + top: $action-bar-height; width: 18%; - height: 100%; z-index: 2; cursor: pointer; - opacity: 0; background: transparent; } .left { - position: fixed; + position: absolute; left: 0px; - top: 0px; + top: $action-bar-height; width: 20%; - height: 100%; + background: transparent; + z-index: 2; cursor: pointer; - opacity: 0; - background: transparent; } + + .highlight { background-color: rgba(65, 225, 100, 0.5) !important; animation: fadein .5s both; @@ -246,14 +307,17 @@ $primary-color: #0062cc; animation: fadein .5s both; } + + + .btn { &.btn-secondary { - color: #6c757d; + color: var(--br-actionbar-button-text-color); border-color: transparent; background-color: unset; &:hover, &:focus { - border-color: #545b62; + border-color: var(--br-actionbar-button-hover-border-color); } } @@ -262,18 +326,18 @@ $primary-color: #0062cc; background-color: unset; &:hover, &:focus { - border-color: #545b62; + border-color: var(--br-actionbar-button-hover-border-color); // #545b62; } } span { background-color: unset; - color: #6c757d; + color: var(--br-actionbar-button-text-color); // #6c757d; } i { background-color: unset; - color: #6c757d; + color: var(--br-actionbar-button-text-color); } &:active { diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index e8657e2aa..0607ea408 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -1,18 +1,15 @@ import { AfterViewInit, Component, ElementRef, HostListener, Inject, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; import {DOCUMENT, Location} from '@angular/common'; -import { FormControl, FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; -import { forkJoin, fromEvent, Subject } from 'rxjs'; -import { debounceTime, take, takeUntil } from 'rxjs/operators'; +import { forkJoin, fromEvent, of, Subject } from 'rxjs'; +import { catchError, debounceTime, take, takeUntil, tap } from 'rxjs/operators'; 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 { ReaderService } from 'src/app/_services/reader.service'; import { SeriesService } from 'src/app/_services/series.service'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; - import { BookService } from '../book.service'; import { KEY_CODES, UtilityService } from 'src/app/shared/_services/utility.service'; import { BookChapterItem } from '../_models/book-chapter-item'; @@ -20,19 +17,22 @@ import { animate, state, style, transition, trigger } from '@angular/animations' import { Stack } from 'src/app/shared/data-structures/stack'; import { MemberService } from 'src/app/_services/member.service'; import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; -import { ScrollService } from 'src/app/scroll.service'; import { MangaFormat } from 'src/app/_models/manga-format'; import { LibraryService } from 'src/app/_services/library.service'; import { LibraryType } from 'src/app/_models/library'; -import { ThemeService } from 'src/app/theme.service'; +import { BookTheme } from 'src/app/_models/preferences/book-theme'; +import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode'; +import { PageStyle } from '../reader-settings/reader-settings.component'; +import { User } from 'src/app/_models/user'; +import { ThemeService } from 'src/app/_services/theme.service'; +import { ScrollService } from 'src/app/_services/scroll.service'; +import { PAGING_DIRECTION } from 'src/app/manga-reader/_models/reader-enums'; +import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; -interface PageStyle { - 'font-family': string; - 'font-size': string; - 'line-height': string; - 'margin-left': string; - 'margin-right': string; +enum TabID { + Settings = 1, + TableOfContents = 2 } interface HistoryPoint { @@ -40,7 +40,7 @@ interface HistoryPoint { scrollOffset: number; } -const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up +const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up // TODO: Do I need this or can I change it with new fixed top height const CHAPTER_ID_NOT_FETCHED = -2; const CHAPTER_ID_DOESNT_EXIST = -1; @@ -77,6 +77,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { volumeId!: number; chapterId!: number; chapter!: Chapter; + user!: User; + /** * Reading List id. Defaults to -1. */ @@ -86,43 +88,68 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * If this is true, no progress will be saved. */ incognitoMode: boolean = false; - + /** - * If this is true, chapters will be fetched in the order of a reading list, rather than natural series order. + * If this is true, chapters will be fetched in the order of a reading list, rather than natural series order. */ readingListMode: boolean = false; + /** + * The actual pages from the epub, used for showing on table of contents. This must be here as we need access to it for scroll anchors + */ chapters: Array = []; - + /** + * Current Page + */ pageNum = 0; + /** + * Max Pages + */ maxPages = 1; + /** + * This allows for exploration into different chapters + */ adhocPageHistory: Stack = new Stack(); /** * A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls. * @see Stack */ - continuousChaptersStack: Stack = new Stack(); - - user!: User; + continuousChaptersStack: Stack = new Stack(); // TODO: See if continuousChaptersStack can be moved into reader service so we can reduce code duplication between readers (and also use ChapterInfo with it instead) + /** + * Belongs to the drawer component + */ + activeTabId: TabID = TabID.Settings; + /** + * Belongs to drawer component + */ drawerOpen = false; - isLoading = true; + /** + * If the action bar is visible + */ + actionBarVisible = true; + /** + * Book reader setting that hides the menuing system + */ + immersiveMode: boolean = false; + /** + * If we are loading from backend + */ + isLoading = true; + /** + * Title of the book. Rendered in action bars + */ bookTitle: string = ''; - settingsForm: FormGroup = new FormGroup({}); - clickToPaginate = false; - + /** + * The boolean that decides if the clickToPaginate overlay is visible or not. + */ clickToPaginateVisualOverlay = false; clickToPaginateVisualOverlayTimeout: any = undefined; // For animation clickToPaginateVisualOverlayTimeout2: any = undefined; // For kicking off animation, giving enough time to render html - - page: SafeHtml | undefined = undefined; // This is the html we get from the server - styles: SafeHtml | undefined = undefined; // This is the css we get from the server - - @ViewChild('readingHtml', {static: false}) readingHtml!: ElementRef; - @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef; - @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef; - @ViewChild('reader', {static: true}) reader!: ElementRef; - + /** + * This is the html we get from the server + */ + page: SafeHtml | undefined = undefined; /** * Next Chapter Id. This is not garunteed to be a valid ChapterId. Prefetched on page load (non-blocking). */ @@ -157,29 +184,28 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { nextPageDisabled = false; /** - * Internal property used to capture all the different css properties to render on all elements + * Internal property used to capture all the different css properties to render on all elements. This is a cached version that is updated from reader-settings component */ pageStyles!: PageStyle; - /** - * List of all font families user can select from - */ - fontFamilies: Array = []; - - darkMode = false; - backgroundColor: string = 'white'; - readerStyles: string = ''; - darkModeStyleElem!: HTMLElement; - topOffset: number = 0; // Offset for drawer and rendering canvas + /** + * Offset for drawer and rendering canvas. Fixed to 62px. + */ + topOffset: number = 38; /** * Used for showing/hiding bottom action bar. Calculates if there is enough scroll to show it. * Will hide if all content in book is absolute positioned */ scrollbarNeeded = false; readingDirection: ReadingDirection = ReadingDirection.LeftToRight; - - private readonly onDestroy = new Subject(); - + clickToPaginate = false; + /** + * Used solely for fullscreen to apply a hack + */ + darkMode = true; + /** + * A anchors that map to the page number. When you click on one of these, we will load a given page up for the user. + */ pageAnchors: {[n: string]: number } = {}; currentPageAnchor: string = ''; /** @@ -190,184 +216,220 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Library Type used for rendering chapter or issue */ libraryType: LibraryType = LibraryType.Book; - - /** - * Hack: Override background color for reader and restore it onDestroy - */ - originalBodyColor: string | undefined; - /** * If the web browser is in fullscreen mode */ isFullscreen: boolean = false; - darkModeStyles = ` - *:not(input), *:not(select), *:not(code), *:not(:link), *:not(.ngx-toastr) { - color: #dcdcdc !important; - } + /** + * How to render the page content + */ + layoutMode: BookPageLayoutMode = BookPageLayoutMode.Default; - code { - color: #e83e8c !important; - } + /** + * Width of the document (in non-column layout), used for column layout virtual paging + */ + windowWidth: number = 0; + windowHeight: number = 0; - :link, a { - color: #8db2e5 !important; - } + /** + * used to track if a click is a drag or not, for opening menu + */ + mousePosition = { + x: 0, + y: 0 + }; - img, img[src] { - z-index: 1; - filter: brightness(0.85) !important; - background-color: initial !important; - } - `; + /** + * Used to keep track of direction user is paging, to help with virtual paging on column layout + */ + pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; + + private readonly onDestroy = new Subject(); + + @ViewChild('readingHtml', {static: false}) readingHtml!: ElementRef; + @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef; + @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef; + @ViewChild('reader', {static: true}) reader!: ElementRef; + + + + + + get BookPageLayoutMode() { + return BookPageLayoutMode; + } + + get TabID(): typeof TabID { + return TabID; + } get ReadingDirection(): typeof ReadingDirection { return ReadingDirection; } + get PAGING_DIRECTION() { + return PAGING_DIRECTION; + } + + /** + * Disables the Left most button + */ get IsPrevDisabled(): boolean { if (this.readingDirection === ReadingDirection.LeftToRight) { // Acting as Previous button - return this.prevPageDisabled && this.pageNum === 0; - } else { - // Acting as a Next button - return this.nextPageDisabled && this.pageNum + 1 > this.maxPages - 1; + return this.isPrevPageDisabled(); } + + // Acting as a Next button + return this.isNextPageDisabled(); } get IsNextDisabled(): boolean { if (this.readingDirection === ReadingDirection.LeftToRight) { // Acting as Next button - return this.nextPageDisabled && this.pageNum + 1 > this.maxPages - 1; - } else { - // Acting as Previous button - return this.prevPageDisabled && this.pageNum === 0; + return this.isNextPageDisabled(); + } + // Acting as Previous button + return this.isPrevPageDisabled(); + } + + isNextPageDisabled() { + const [currentVirtualPage, totalVirtualPages, _] = this.getVirtualPage(); + const condition = (this.nextPageDisabled || this.nextChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum + 1 > this.maxPages - 1; + if (this.layoutMode !== BookPageLayoutMode.Default) { + return condition && currentVirtualPage === totalVirtualPages; + } + return condition; + } + + isPrevPageDisabled() { + const [currentVirtualPage,,] = this.getVirtualPage(); + const condition = (this.prevPageDisabled || this.prevChapterId === CHAPTER_ID_DOESNT_EXIST) && this.pageNum === 0; + if (this.layoutMode !== BookPageLayoutMode.Default) { + return condition && currentVirtualPage === 0; + } + return condition; + } + + /** + * Determines if we show >> or > + */ + get IsNextChapter(): boolean { + if (this.layoutMode === BookPageLayoutMode.Default) { + return this.pageNum + 1 >= this.maxPages; + } + + const [currentVirtualPage, totalVirtualPages, _] = this.getVirtualPage(); + if (this.readingHtml == null) return this.pageNum + 1 >= this.maxPages; + + return this.pageNum + 1 >= this.maxPages && (currentVirtualPage === totalVirtualPages); + } + /** + * Determines if we show << or < + */ + get IsPrevChapter(): boolean { + if (this.layoutMode === BookPageLayoutMode.Default) { + return this.pageNum === 0; + } + + const [currentVirtualPage,,] = this.getVirtualPage(); + if (this.readingHtml == null) return this.pageNum + 1 >= this.maxPages; + + return this.pageNum === 0 && (currentVirtualPage === 0); + } + + get ColumnWidth() { + switch (this.layoutMode) { + case BookPageLayoutMode.Default: + return 'unset'; + case BookPageLayoutMode.Column1: + return (this.windowWidth /2) + 'px'; + case BookPageLayoutMode.Column2: + return ((this.windowWidth / 4)) + 'px'; } } - get IsNextChapter(): boolean { - return this.pageNum + 1 >= this.maxPages; - } - get IsPrevChapter(): boolean { - return this.pageNum === 0; + get ColumnHeight() { + if (this.layoutMode !== BookPageLayoutMode.Default) { + // Take the height after page loads, subtract the top/bottom bar + return this.windowHeight - (this.topOffset *2) + 'px'; + } + return 'unset'; } - get drawerBackgroundColor() { - return this.darkMode ? '#010409': '#fff'; + get ColumnLayout() { + switch (this.layoutMode) { + case BookPageLayoutMode.Default: + return ''; + case BookPageLayoutMode.Column1: + return 'column-layout-1'; + case BookPageLayoutMode.Column2: + return 'column-layout-2'; + } } + get PageHeightForPagination() { + if (this.layoutMode === BookPageLayoutMode.Default) { + return (this.readingSectionElemRef?.nativeElement?.scrollHeight || 0) - (this.topOffset * 2) + 'px'; + } + + return this.ColumnHeight; + } + + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private seriesService: SeriesService, private readerService: ReaderService, private location: Location, - private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, + private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService, private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService, @Inject(DOCUMENT) private document: Document, private themeService: ThemeService) { this.navService.hideNavBar(); + this.themeService.clearThemes(); this.navService.hideSideNav(); - - this.darkModeStyleElem = this.renderer.createElement('style'); - this.darkModeStyleElem.innerHTML = this.darkModeStyles; - this.fontFamilies = this.bookService.getFontFamilies(); - - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (user) { - this.user = user; - - if (this.user.preferences.bookReaderFontFamily === undefined) { - this.user.preferences.bookReaderFontFamily = 'default'; - } - if (this.user.preferences.bookReaderFontSize === undefined) { - this.user.preferences.bookReaderFontSize = 100; - } - if (this.user.preferences.bookReaderLineSpacing === undefined) { - this.user.preferences.bookReaderLineSpacing = 100; - } - if (this.user.preferences.bookReaderMargin === undefined) { - this.user.preferences.bookReaderMargin = 0; - } - if (this.user.preferences.bookReaderReadingDirection === undefined) { - this.user.preferences.bookReaderReadingDirection = ReadingDirection.LeftToRight; - } - - this.readingDirection = this.user.preferences.bookReaderReadingDirection; - - this.clickToPaginate = this.user.preferences.bookReaderTapToPaginate; - - this.settingsForm.addControl('bookReaderFontFamily', new FormControl(user.preferences.bookReaderFontFamily, [])); - - this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(changes => { - this.updateFontFamily(changes); - }); - } - - const bodyNode = this.document.querySelector('body'); - if (bodyNode !== undefined && bodyNode !== null) { - this.originalBodyColor = bodyNode.style.background; - } - this.resetSettings(); - }); } /** - * After the page has loaded, setup the scroll handler. The scroll handler has 2 parts. One is if there are page anchors setup (aka page anchor elements linked with the - * table of content) then we calculate what has already been reached and grab the last reached one to save progress. If page anchors aren't setup (toc missing), then try to save progress + * After the page has loaded, setup the scroll handler. The scroll handler has 2 parts. One is if there are page anchors setup (aka page anchor elements linked with the + * table of content) then we calculate what has already been reached and grab the last reached one to save progress. If page anchors aren't setup (toc missing), then try to save progress * based on the last seen scroll part (xpath). */ ngAfterViewInit() { // check scroll offset and if offset is after any of the "id" markers, save progress fromEvent(this.reader.nativeElement, 'scroll') - .pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => { + .pipe( + debounceTime(200), + takeUntil(this.onDestroy)) + .subscribe((event) => { if (this.isLoading) return; - // Highlight the current chapter we are on - if (Object.keys(this.pageAnchors).length !== 0) { - // get the height of the document so we can capture markers that are halfway on the document viewport - const verticalOffset = this.scrollService.scrollPosition + (this.document.body.offsetHeight / 2); - - const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); - if (alreadyReached.length > 0) { - this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1]; - } else { - this.currentPageAnchor = ''; - } - } - - - // Find the element that is on screen to bookmark against - const intersectingEntries = Array.from(this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) - .filter(element => !element.classList.contains('no-observe')) - .filter(entry => { - return this.utilityService.isInViewport(entry, this.topOffset); - }); - - intersectingEntries.sort((a: Element, b: Element) => { - const aTop = a.getBoundingClientRect().top; - const bTop = b.getBoundingClientRect().top; - if (aTop < bTop) { - return -1; - } - if (aTop > bTop) { - return 1; - } - - return 0; - }); - - if (intersectingEntries.length > 0) { - let path = this.getXPathTo(intersectingEntries[0]); - if (path === '') { return; } - if (!path.startsWith('id')) { - path = '//html[1]/' + path; - } - this.lastSeenScrollPartPath = path; - } - - if (this.lastSeenScrollPartPath !== '') { - this.saveProgress(); - } + this.handleScrollEvent(); }); } + handleScrollEvent() { + // Highlight the current chapter we are on + if (Object.keys(this.pageAnchors).length !== 0) { + // get the height of the document so we can capture markers that are halfway on the document viewport + const verticalOffset = this.scrollService.scrollPosition + (this.document.body.offsetHeight / 2); + + const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); + if (alreadyReached.length > 0) { + this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1]; + } else { + this.currentPageAnchor = ''; + } + } + + // Find the element that is on screen to bookmark against + const xpath: string | null | undefined = this.getFirstVisibleElementXPath(); + if (xpath !== null && xpath !== undefined) this.lastSeenScrollPartPath = xpath; + + if (this.lastSeenScrollPartPath !== '') { + this.saveProgress(); + } + } + saveProgress() { let tempPageNum = this.pageNum; if (this.pageNum == this.maxPages - 1) { @@ -381,28 +443,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy(): void { - const bodyNode = this.document.querySelector('body'); - if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) { - bodyNode.style.background = this.originalBodyColor; - this.themeService.currentTheme$.pipe(take(1)).subscribe(theme => { - this.themeService.setTheme(theme.name); - }); - } + this.clearTimeout(this.clickToPaginateVisualOverlayTimeout); + this.clearTimeout(this.clickToPaginateVisualOverlayTimeout2); + + this.themeService.clearBookTheme(); + + this.themeService.currentTheme$.pipe(take(1)).subscribe(theme => { + this.themeService.setTheme(theme.name); + }); + this.navService.showNavBar(); this.navService.showSideNav(); - - const head = this.document.querySelector('head'); - this.renderer.removeChild(head, this.darkModeStyleElem); - - if (this.clickToPaginateVisualOverlayTimeout !== undefined) { - clearTimeout(this.clickToPaginateVisualOverlayTimeout); - this.clickToPaginateVisualOverlayTimeout = undefined; - } - if (this.clickToPaginateVisualOverlayTimeout2 !== undefined) { - clearTimeout(this.clickToPaginateVisualOverlayTimeout2); - this.clickToPaginateVisualOverlayTimeout2 = undefined; - } - this.readerService.exitFullscreen(); this.onDestroy.next(); @@ -415,7 +466,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const chapterId = this.route.snapshot.paramMap.get('chapterId'); if (libraryId === null || seriesId === null || chapterId === null) { - this.router.navigateByUrl('/library'); + this.router.navigateByUrl('/libraries'); return; } @@ -439,7 +490,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } }); - this.init(); + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + this.user = user; + this.init(); + } + }); } init() { @@ -449,11 +505,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.prevChapterDisabled = false; this.nextChapterPrefetched = false; + + this.bookService.getBookInfo(this.chapterId).subscribe(info => { this.bookTitle = info.bookTitle; - + if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) { - // Redirect to the manga reader. + // Redirect to the manga reader. const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId); this.router.navigate(['library', info.libraryId, 'series', info.seriesId, 'manga', this.chapterId], {queryParams: params}); return; @@ -463,40 +521,51 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { chapter: this.seriesService.getChapter(this.chapterId), progress: this.readerService.getProgress(this.chapterId), chapters: this.bookService.getBookChapters(this.chapterId), - }).pipe(take(1)).subscribe(results => { + }).subscribe(results => { this.chapter = results.chapter; this.volumeId = results.chapter.volumeId; this.maxPages = results.chapter.pages; this.chapters = results.chapters; this.pageNum = results.progress.pageNum; - - + if (results.progress.bookScrollId) this.lastSeenScrollPartPath = results.progress.bookScrollId; + + + this.continuousChaptersStack.push(this.chapterId); this.libraryService.getLibraryType(this.libraryId).pipe(take(1)).subscribe(type => { this.libraryType = type; }); - - - + + // We need to think about if the user modified this and this function call is a continuous reader one + //this.updateLayoutMode(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default); + this.updateImagesWithHeight(); + + if (this.pageNum >= this.maxPages) { this.pageNum = this.maxPages - 1; this.saveProgress(); } - + this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.nextChapterId = chapterId; if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { this.nextChapterDisabled = true; + this.nextChapterPrefetched = true; + return; } + this.setPageNum(this.pageNum); }); this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.prevChapterId = chapterId; if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { this.prevChapterDisabled = true; + this.prevChapterPrefetched = true; // If there is no prev chapter, then mark it as prefetched + return; } + this.setPageNum(this.pageNum); }); - + // Check if user progress has part, if so load it so we scroll to it this.loadPage(results.progress.bookScrollId || undefined); }, () => { @@ -505,22 +574,41 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { }, 200); }); }); + } - + @HostListener('window:resize', ['$event']) + onResize(event: any){ + // Update the window Height + this.updateWidthAndHeightCalcs(); + + const resumeElement = this.getFirstVisibleElementXPath(); + if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { + this.scrollTo(resumeElement); // This works pretty well, but not perfect + } + } + + @HostListener('window:orientationchange', ['$event']) + onOrientationChange() { + // Update the window Height + this.updateWidthAndHeightCalcs(); + const resumeElement = this.getFirstVisibleElementXPath(); + if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { + this.scrollTo(resumeElement); // This works pretty well, but not perfect + } } @HostListener('window:keydown', ['$event']) handleKeyPress(event: KeyboardEvent) { if (event.key === KEY_CODES.RIGHT_ARROW) { - this.nextPage(); + this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS); } else if (event.key === KEY_CODES.LEFT_ARROW) { - this.prevPage(); + this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD); } else if (event.key === KEY_CODES.ESC_KEY) { this.closeReader(); } else if (event.key === KEY_CODES.SPACE) { this.toggleDrawer(); event.stopPropagation(); - event.preventDefault(); + event.preventDefault(); } else if (event.key === KEY_CODES.G) { this.goToPage(); } else if (event.key === KEY_CODES.F) { @@ -556,6 +644,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { loadPrevChapter() { if (this.prevPageDisabled) { return; } + this.isLoading = true; this.continuousChaptersStack.pop(); const prevChapter = this.continuousChaptersStack.peek(); @@ -567,7 +656,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) { + if (this.prevChapterPrefetched && this.prevChapterId === CHAPTER_ID_DOESNT_EXIST) { + return; + } + + if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId && !this.prevChapterPrefetched) { this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.prevChapterId = chapterId; this.loadChapter(chapterId, 'Prev'); @@ -580,7 +673,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { loadChapter(chapterId: number, direction: 'Next' | 'Prev') { if (chapterId >= 0) { this.chapterId = chapterId; - this.continuousChaptersStack.push(chapterId); + this.continuousChaptersStack.push(chapterId); // Load chapter Id onto route but don't reload const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); @@ -598,9 +691,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - loadChapterPage(pageNum: number, part: string) { - this.setPageNum(pageNum); - this.loadPage('id("' + part + '")'); + loadChapterPage(event: {pageNum: number, part: string}) { + this.setPageNum(event.pageNum); + this.loadPage('id("' + event.part + '")'); } closeReader() { @@ -611,34 +704,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - resetSettings() { - const windowWidth = window.innerWidth - || this.document.documentElement.clientWidth - || this.document.body.clientWidth; - - let margin = '15%'; - if (windowWidth <= 700) { - margin = '5%'; - } - if (this.user) { - if (windowWidth > 700) { - margin = this.user.preferences.bookReaderMargin + '%'; - } - this.pageStyles = {'font-family': this.user.preferences.bookReaderFontFamily, 'font-size': this.user.preferences.bookReaderFontSize + '%', 'margin-left': margin, 'margin-right': margin, 'line-height': this.user.preferences.bookReaderLineSpacing + '%'}; - - this.toggleDarkMode(this.user.preferences.bookReaderDarkMode); - } else { - this.pageStyles = {'font-family': 'default', 'font-size': '100%', 'margin-left': margin, 'margin-right': margin, 'line-height': '100%'}; - this.toggleDarkMode(false); - } - - this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily); - this.updateReaderStyles(); - } /** - * Adds a click handler for any anchors that have 'kavita-page'. If 'kavita-page' present, changes page to kavita-page and optionally passes a part value - * from 'kavita-part', which will cause the reader to scroll to the marker. + * Adds a click handler for any anchors that have 'kavita-page'. If 'kavita-page' present, changes page to kavita-page and optionally passes a part value + * from 'kavita-part', which will cause the reader to scroll to the marker. */ addLinkClickHandlers() { var links = this.readingSectionElemRef.nativeElement.querySelectorAll('a'); @@ -649,13 +718,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.adhocPageHistory.peek()?.page !== this.pageNum) { this.adhocPageHistory.push({page: this.pageNum, scrollOffset: window.pageYOffset}); } - + var partValue = e.target.attributes.hasOwnProperty('kavita-part') ? e.target.attributes['kavita-part'].value : undefined; if (partValue && page === this.pageNum) { this.scrollTo(e.target.attributes['kavita-part'].value); return; } - + this.setPageNum(page); this.loadPage(partValue); }); @@ -669,6 +738,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + promptForPage() { const question = 'There are ' + (this.maxPages - 1) + ' pages. What page do you want to go to?'; const goToPageNum = window.prompt(question, ''); @@ -698,9 +768,462 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.pageNum = page; this.loadPage(); - } + + + + loadPage(part?: string | undefined, scrollTop?: number | undefined) { + this.isLoading = true; + + this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => { + this.page = this.domSanitizer.bypassSecurityTrustHtml(content); // PERF: Potential optimization to prefetch next/prev page and store in localStorage + + setTimeout(() => { + this.addLinkClickHandlers(); + this.updateReaderStyles(this.pageStyles); + this.updateReaderStyles(this.pageStyles); + + const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img'); + if (imgs === null || imgs.length === 0) { + this.setupPage(part, scrollTop); + return; + } + + Promise.all(Array.from(imgs) + .filter(img => !img.complete) + .map(img => new Promise(resolve => { img.onload = img.onerror = resolve; }))) + .then(() => { + this.setupPage(part, scrollTop); + this.updateImagesWithHeight(); + }); + }, 10); + }); + } + + /** + * Applies a max-height inline css property on each image in the page if the layout mode is column-based, else it removes the property + */ + updateImagesWithHeight() { + + const images = this.readingSectionElemRef?.nativeElement.querySelectorAll('img') || []; + + if (this.layoutMode !== BookPageLayoutMode.Default) { + const height = this.ColumnHeight; + Array.from(images).forEach(img => { + this.renderer.setStyle(img, 'max-height', height); + }); + } else { + Array.from(images).forEach(img => { + this.renderer.removeStyle(img, 'max-height'); + }); + } + } + + setupPage(part?: string | undefined, scrollTop?: number | undefined) { + this.isLoading = false; + + // Virtual Paging stuff + this.updateWidthAndHeightCalcs(); + this.updateLayoutMode(this.layoutMode || BookPageLayoutMode.Default); + + // Find all the part ids and their top offset + this.setupPageAnchors(); + + + if (part !== undefined && part !== '') { + this.scrollTo(part); + } else if (scrollTop !== undefined && scrollTop !== 0) { + this.scrollService.scrollTo(scrollTop, this.reader.nativeElement); + } else { + + if (this.layoutMode === BookPageLayoutMode.Default) { + this.scrollService.scrollTo(0, this.reader.nativeElement); + } else { + this.reader.nativeElement.children + // We need to check if we are paging back, because we need to adjust the scroll + if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { + setTimeout(() => this.scrollService.scrollToX(this.readingHtml.nativeElement.scrollWidth, this.readingHtml.nativeElement)); + } else { + setTimeout(() => this.scrollService.scrollToX(0, this.readingHtml.nativeElement)); + } + } + } + + // we need to click the document before arrow keys will scroll down. + this.reader.nativeElement.focus(); + this.saveProgress(); + } + + + goBack() { + if (!this.adhocPageHistory.isEmpty()) { + const page = this.adhocPageHistory.pop(); + if (page !== undefined) { + this.setPageNum(page.page); + this.loadPage(undefined, page.scrollOffset); + } + } + } + + setPageNum(pageNum: number) { + this.pageNum = Math.max(Math.min(pageNum, this.maxPages), 0); + + if (this.pageNum >= this.maxPages - 10) { + // Tell server to cache the next chapter + if (!this.nextChapterPrefetched && this.nextChapterId !== CHAPTER_ID_DOESNT_EXIST) { // && !this.nextChapterDisabled + this.readerService.getChapterInfo(this.nextChapterId).pipe(take(1), catchError(err => { + this.nextChapterDisabled = true; + return of(null); + })).subscribe(res => { + this.nextChapterPrefetched = true; + }); + } + } else if (this.pageNum <= 10) { + if (!this.prevChapterPrefetched && this.prevChapterId !== CHAPTER_ID_DOESNT_EXIST) { // && !this.prevChapterDisabled + this.readerService.getChapterInfo(this.prevChapterId).pipe(take(1), catchError(err => { + this.prevChapterDisabled = true; + return of(null); + })).subscribe(res => { + this.prevChapterPrefetched = true; + }); + } + } + } + + /** + * Given a direction, calls the next or prev page method + * @param direction Direction to move + */ + movePage(direction: PAGING_DIRECTION) { + if (direction === PAGING_DIRECTION.BACKWARDS) { + this.prevPage(); + return; + } + + this.nextPage(); + } + + prevPage() { + const oldPageNum = this.pageNum; + + this.pagingDirection = PAGING_DIRECTION.BACKWARDS; + + // We need to handle virtual paging before we increment the actual page + if (this.layoutMode !== BookPageLayoutMode.Default) { + const [currentVirtualPage, _, pageWidth] = this.getVirtualPage(); + + if (currentVirtualPage > 1) { + // -2 apparently goes back 1 virtual page... + this.scrollService.scrollToX((currentVirtualPage - 2) * pageWidth, this.readingHtml.nativeElement); + this.handleScrollEvent(); + return; + } + } + + this.setPageNum(this.pageNum - 1); + + if (oldPageNum === 0) { + // Move to next volume/chapter automatically + this.loadPrevChapter(); + return; + } + + if (oldPageNum === this.pageNum) { return; } + this.loadPage(); + } + + nextPage(event?: any) { + if (event) { + event.stopPropagation(); + event.preventDefault(); + } + + this.pagingDirection = PAGING_DIRECTION.FORWARD; + + // We need to handle virtual paging before we increment the actual page + if (this.layoutMode !== BookPageLayoutMode.Default) { + const [currentVirtualPage, totalVirtualPages, pageWidth] = this.getVirtualPage(); + + if (currentVirtualPage < totalVirtualPages) { + // +0 apparently goes forward 1 virtual page... + this.scrollService.scrollToX((currentVirtualPage) * pageWidth, this.readingHtml.nativeElement); + this.handleScrollEvent(); + return; + } + } + + const oldPageNum = this.pageNum; + if (oldPageNum + 1 === this.maxPages) { + // Move to next volume/chapter automatically + this.loadNextChapter(); + return; + } + + + this.setPageNum(this.pageNum + 1); + + if (oldPageNum === this.pageNum) { return; } + + this.loadPage(); + } + + /** + * + * @returns Total Page width (excluding margin) + */ + getPageWidth() { + if (this.readingSectionElemRef == null) return 0; + + const margin = (this.readingSectionElemRef.nativeElement.clientWidth*(parseInt(this.pageStyles['margin-left'], 10) / 100))*2; + const columnGap = 20; + return this.readingSectionElemRef.nativeElement.clientWidth - margin + columnGap; + } + + /** + * currentVirtualPage starts at 1 + * @returns + */ + getVirtualPage() { + if (this.readingHtml === undefined || this.readingSectionElemRef === undefined) return [1, 1, 0]; + + const scrollOffset = this.readingHtml.nativeElement.scrollLeft; + const totalScroll = this.readingHtml.nativeElement.scrollWidth; + const pageWidth = this.getPageWidth(); + const delta = totalScroll - scrollOffset; + + const totalVirtualPages = Math.max(1, Math.round((totalScroll) / pageWidth)); + let currentVirtualPage = 1; + + // If first virtual page, i.e. totalScroll and delta are the same value + if (totalScroll - delta === 0) { + currentVirtualPage = 1; + // If second virtual page + } else if (totalScroll - delta === pageWidth) { + currentVirtualPage = 2; + + // Otherwise do math to get correct page. i.e. scrollOffset + pageWidth (this accounts for first page offset) + } else { + currentVirtualPage = Math.min(Math.max(1, Math.round((scrollOffset + pageWidth) / pageWidth)), totalVirtualPages); + } + + return [currentVirtualPage, totalVirtualPages, pageWidth]; + } + + getFirstVisibleElementXPath() { + let resumeElement: string | null = null; + if (this.readingHtml === null) return null; + + const intersectingEntries = Array.from(this.readingHtml.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) + .filter(element => !element.classList.contains('no-observe')) + .filter(entry => { + return this.utilityService.isInViewport(entry, this.topOffset); + }); + + intersectingEntries.sort(this.sortElements); + + if (intersectingEntries.length > 0) { + let path = this.getXPathTo(intersectingEntries[0]); + if (path === '') { return; } + if (!path.startsWith('id')) { + path = '//html[1]/' + path; + } + resumeElement = path; + } + return resumeElement; + } + + /** + * Applies styles onto the html of the book page + */ + updateReaderStyles(pageStyles: PageStyle) { + this.pageStyles = pageStyles; + if (this.readingHtml === undefined || !this.readingHtml.nativeElement) return; + + // Before we apply styles, let's get an element on the screen so we can scroll to it after any shifts + const resumeElement: string | null | undefined = this.getFirstVisibleElementXPath(); + + + // Line Height must be placed on each element in the page + + // Apply page level overrides + Object.entries(this.pageStyles).forEach(item => { + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]); + return; + } + if (pageLevelStyles.includes(item[0])) { + this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important); + } + }); + + const individualElementStyles = Object.entries(this.pageStyles).filter(item => elementLevelStyles.includes(item[0])); + for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { + const elem = this.readingHtml.nativeElement.children.item(i); + if (elem?.tagName === 'STYLE') continue; + individualElementStyles.forEach(item => { + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(elem, item[0]); + return; + } + this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); + }); + } + + // After layout shifts, we need to refocus the scroll bar + if (this.layoutMode !== BookPageLayoutMode.Default && resumeElement !== null && resumeElement !== undefined) { + this.updateWidthAndHeightCalcs(); + this.scrollTo(resumeElement); // This works pretty well, but not perfect + } + } + + /** + * Applies styles and classes that control theme + * @param theme + */ + updateColorTheme(theme: BookTheme) { + // Remove all themes + Array.from(this.document.querySelectorAll('style[id^="brtheme-"]')).forEach(elem => elem.remove()); + + this.darkMode = theme.isDarkTheme; + + const styleElem = this.renderer.createElement('style'); + styleElem.id = theme.selector; + styleElem.innerHTML = theme.content; + + + this.renderer.appendChild(this.document.querySelector('.reading-section'), styleElem); + // I need to also apply the selector onto the body so that any css variables will take effect + this.themeService.setBookTheme(theme.selector); + } + + updateWidthAndHeightCalcs() { + this.windowHeight = Math.max(this.readingSectionElemRef.nativeElement.clientHeight, window.innerHeight); + this.windowWidth = Math.max(this.readingSectionElemRef.nativeElement.clientWidth, window.innerWidth); + + // Recalculate if bottom action bar is needed + this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.reader.nativeElement.clientHeight; + } + + toggleDrawer() { + this.drawerOpen = !this.drawerOpen; + + if (this.immersiveMode) { + this.actionBarVisible = false; + } + } + + scrollTo(partSelector: string) { + if (partSelector.startsWith('#')) { + partSelector = partSelector.substr(1, partSelector.length); + } + + let element: Element | null = null; + if (partSelector.startsWith('//') || partSelector.startsWith('id(')) { + // Part selector is a XPATH + element = this.getElementFromXPath(partSelector); + } else { + element = this.document.querySelector('*[id="' + partSelector + '"]'); + } + + if (element === null) return; + + if (this.layoutMode === BookPageLayoutMode.Default) { + const fromTopOffset = element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET; + // We need to use a delay as webkit browsers (aka apple devices) don't always have the document rendered by this point + setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); + } else { + setTimeout(() => (element as Element).scrollIntoView({'block': 'start', 'inline': 'start'})); + } + } + + + getElementFromXPath(path: string) { + const node = this.document.evaluate(path, this.document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + if (node?.nodeType === Node.ELEMENT_NODE) { + return node as Element; + } + return null; + } + + getXPathTo(element: any): string { + if (element === null) return ''; + if (element.id !== '') { return 'id("' + element.id + '")'; } + if (element === this.document.body) { return element.tagName; } + + + let ix = 0; + const siblings = element.parentNode?.childNodes || []; + for (let sibling of siblings) { + if (sibling === element) { + return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']'; + } + if (sibling.nodeType === 1 && sibling.tagName === element.tagName) { + ix++; + } + + } + return ''; + } + + /** + * Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state + */ + turnOffIncognito() { + this.incognitoMode = false; + const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); + window.history.replaceState({}, '', newRoute); + this.toastr.info('Incognito mode is off. Progress will now start being tracked.'); + this.saveProgress(); + } + + toggleFullscreen() { + this.isFullscreen = this.readerService.checkFullscreenMode(); + if (this.isFullscreen) { + this.readerService.exitFullscreen(() => { + this.isFullscreen = false; + this.renderer.removeStyle(this.reader.nativeElement, 'background'); + }); + } else { + this.readerService.enterFullscreen(this.reader.nativeElement, () => { + this.isFullscreen = true; + // HACK: This is a bug with how browsers change the background color for fullscreen mode + this.renderer.setStyle(this.reader.nativeElement, 'background', this.themeService.getCssVariable('--bs-body-color')); + if (!this.darkMode) { + this.renderer.setStyle(this.reader.nativeElement, 'background', 'white'); + } + }); + } + } + + updateLayoutMode(mode: BookPageLayoutMode) { + this.layoutMode = mode; + + // Remove any max-heights from column layout + this.updateImagesWithHeight(); + + // Calulate if bottom actionbar is needed. On a timeout to get accurate heights + if (this.readingHtml == null) { + setTimeout(() => this.updateLayoutMode(this.layoutMode), 10); + return; + } + setTimeout(() => {this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.reader.nativeElement.clientHeight;}); + } + + updateReadingDirection(readingDirection: ReadingDirection) { + this.readingDirection = readingDirection; + } + + updateImmersiveMode(immersiveMode: boolean) { + this.immersiveMode = immersiveMode; + if (this.immersiveMode && !this.drawerOpen) { + this.actionBarVisible = false; + } + } + + // Table of Contents cleanIdSelector(id: string) { const tokens = id.split('/'); if (tokens.length > 0) { @@ -730,308 +1253,25 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - loadPage(part?: string | undefined, scrollTop?: number | undefined) { - this.isLoading = true; + // Settings Handlers + showPaginationOverlay(clickToPaginate: boolean) { + this.clickToPaginate = clickToPaginate; - this.saveProgress(); - - this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => { - this.page = this.domSanitizer.bypassSecurityTrustHtml(content); - setTimeout(() => { - this.addLinkClickHandlers(); - this.updateReaderStyles(); - // We need to get the offset after we ensure the title has rendered - requestAnimationFrame(() => this.topOffset = this.stickyTopElemRef.nativeElement?.getBoundingClientRect().height); - - const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img'); - if (imgs === null || imgs.length === 0) { - this.setupPage(part, scrollTop); - return; - } - - // Apply scaling class to all images to ensure they scale down to max width to not blow out the reader - Array.from(imgs).forEach(img => this.renderer.addClass(img, 'scale-width')); - - Promise.all(Array.from(imgs) - .filter(img => !img.complete) - .map(img => new Promise(resolve => { img.onload = img.onerror = resolve; }))) - .then(() => { - this.setupPage(part, scrollTop); - }); - }, 10); - }); - } - - setupPage(part?: string | undefined, scrollTop?: number | undefined) { - this.isLoading = false; - this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.readingSectionElemRef.nativeElement.clientHeight; - - // Find all the part ids and their top offset - this.setupPageAnchors(); - - - if (part !== undefined && part !== '') { - this.scrollTo(part); - } else if (scrollTop !== undefined && scrollTop !== 0) { - this.scrollService.scrollTo(scrollTop, this.reader.nativeElement); - } else { - this.scrollService.scrollTo(0, this.reader.nativeElement); - } - - // we need to click the document before arrow keys will scroll down. - this.reader.nativeElement.focus(); - } - - setPageNum(pageNum: number) { - this.pageNum = Math.max(Math.min(pageNum, this.maxPages), 0); - } - - goBack() { - if (!this.adhocPageHistory.isEmpty()) { - const page = this.adhocPageHistory.pop(); - if (page !== undefined) { - this.setPageNum(page.page); - this.loadPage(undefined, page.scrollOffset); - } - } - } - - clickOverlayClass(side: 'right' | 'left') { - if (!this.clickToPaginateVisualOverlay) { - return ''; - } - - if (this.readingDirection === ReadingDirection.LeftToRight) { - return side === 'right' ? 'highlight' : 'highlight-2'; - } - return side === 'right' ? 'highlight-2' : 'highlight'; - } - - prevPage() { - const oldPageNum = this.pageNum; - - if (this.readingDirection === ReadingDirection.LeftToRight) { - this.setPageNum(this.pageNum - 1); - } else { - this.setPageNum(this.pageNum + 1); - } - - if (oldPageNum === 0) { - // Move to next volume/chapter automatically - this.loadPrevChapter(); - return; - } - - if (oldPageNum === this.pageNum) { return; } - - this.loadPage(); - } - - nextPage(event?: any) { - if (event) { - event.stopPropagation(); - event.preventDefault(); - } - const oldPageNum = this.pageNum; - if (oldPageNum + 1 === this.maxPages) { - // Move to next volume/chapter automatically - this.loadNextChapter(); - return; - } - - - if (this.readingDirection === ReadingDirection.LeftToRight) { - this.setPageNum(this.pageNum + 1); - } else { - this.setPageNum(this.pageNum - 1); - } - - - - if (oldPageNum === this.pageNum) { return; } - - - this.loadPage(); - } - - updateFontSize(amount: number) { - let val = parseInt(this.pageStyles['font-size'].substr(0, this.pageStyles['font-size'].length - 1), 10); - - if (val + amount > 300 || val + amount < 50) { - return; - } - - this.pageStyles['font-size'] = val + amount + '%'; - this.updateReaderStyles(); - } - - updateFontFamily(familyName: string) { - if (familyName === null) familyName = ''; - let cleanedName = familyName.replace(' ', '_').replace('!important', '').trim(); - if (cleanedName === 'default') { - this.pageStyles['font-family'] = 'inherit'; - } else { - this.pageStyles['font-family'] = "'" + cleanedName + "'"; - } - - this.updateReaderStyles(); - } - - updateMargin(amount: number) { - let cleanedValue = this.pageStyles['margin-left'].replace('%', '').replace('!important', '').trim(); - let val = parseInt(cleanedValue, 10); - - if (val + amount > 30 || val + amount < 0) { - return; - } - - this.pageStyles['margin-left'] = (val + amount) + '%'; - this.pageStyles['margin-right'] = (val + amount) + '%'; - - this.updateReaderStyles(); - } - - updateLineSpacing(amount: number) { - const cleanedValue = parseInt(this.pageStyles['line-height'].replace('%', '').replace('!important', '').trim(), 10); - - if (cleanedValue + amount > 250 || cleanedValue + amount < 100) { - return; - } - - this.pageStyles['line-height'] = (cleanedValue + amount) + '%'; - - this.updateReaderStyles(); - } - - /** - * Applies styles onto the html of the book page - */ - updateReaderStyles() { - if (this.readingHtml === undefined || !this.readingHtml.nativeElement) return; - - // Line Height must be placed on each element in the page - - // Apply page level overrides - Object.entries(this.pageStyles).forEach(item => { - if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { - // Remove the style or skip - this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]); - return; - } - if (pageLevelStyles.includes(item[0])) { - this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important); - } - }); - - const individualElementStyles = Object.entries(this.pageStyles).filter(item => elementLevelStyles.includes(item[0])); - for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { - const elem = this.readingHtml.nativeElement.children.item(i); - if (elem?.tagName === 'STYLE') continue; - individualElementStyles.forEach(item => { - if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { - // Remove the style or skip - this.renderer.removeStyle(elem, item[0]); - return; - } - this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); - }); - - } - - } - - - toggleDarkMode(force?: boolean) { - if (force !== undefined) { - this.darkMode = force; - } else { - this.darkMode = !this.darkMode; - } - - this.setOverrideStyles(); - } - - toggleReadingDirection() { - if (this.readingDirection === ReadingDirection.LeftToRight) { - this.readingDirection = ReadingDirection.RightToLeft; - } else { - this.readingDirection = ReadingDirection.LeftToRight; - } - } - - getDarkModeBackgroundColor() { - return this.darkMode ? '#292929' : '#fff'; - } - - setOverrideStyles() { - const bodyNode = this.document.querySelector('body'); - if (bodyNode !== undefined && bodyNode !== null) { - if (this.themeService.isDarkTheme()) { - bodyNode.classList.remove('bg-dark'); - } - - bodyNode.style.background = this.getDarkModeBackgroundColor(); - } - this.backgroundColor = this.getDarkModeBackgroundColor(); - const head = this.document.querySelector('head'); - if (this.darkMode) { - this.renderer.appendChild(head, this.darkModeStyleElem) - } else { - this.renderer.removeChild(head, this.darkModeStyleElem); - } - } - - toggleDrawer() { - this.topOffset = this.stickyTopElemRef.nativeElement?.offsetHeight; - this.drawerOpen = !this.drawerOpen; - } - - closeDrawer() { - this.drawerOpen = false; - } - - handleReaderClick(event: MouseEvent) { - if (this.drawerOpen) { - this.closeDrawer(); - event.stopPropagation(); - event.preventDefault(); - } - } - - - scrollTo(partSelector: string) { - if (partSelector.startsWith('#')) { - partSelector = partSelector.substr(1, partSelector.length); - } - - let element = null; - if (partSelector.startsWith('//') || partSelector.startsWith('id(')) { - // Part selector is a XPATH - element = this.getElementFromXPath(partSelector); - } else { - element = this.document.querySelector('*[id="' + partSelector + '"]'); - } - - if (element === null) return; - const fromTopOffset = element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET; - // We need to use a delay as webkit browsers (aka apple devices) don't always have the document rendered by this point - setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); - } - - toggleClickToPaginate() { - this.clickToPaginate = !this.clickToPaginate; - - if (this.clickToPaginateVisualOverlayTimeout2 !== undefined) { - clearTimeout(this.clickToPaginateVisualOverlayTimeout2); - this.clickToPaginateVisualOverlayTimeout2 = undefined; - } - if (!this.clickToPaginate) { return; } + this.clearTimeout(this.clickToPaginateVisualOverlayTimeout2); + if (!clickToPaginate) { return; } this.clickToPaginateVisualOverlayTimeout2 = setTimeout(() => { this.showClickToPaginateVisualOverlay(); }, 200); } + clearTimeout(timeoutId: number | undefined) { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + } + showClickToPaginateVisualOverlay() { this.clickToPaginateVisualOverlay = true; @@ -1045,60 +1285,46 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } - getElementFromXPath(path: string) { - const node = this.document.evaluate(path, this.document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; - if (node?.nodeType === Node.ELEMENT_NODE) { - return node as Element; - } - return null; - } - - getXPathTo(element: any): string { - if (element === null) return ''; - if (element.id !== '') { return 'id("' + element.id + '")'; } - if (element === this.document.body) { return element.tagName; } - - - let ix = 0; - const siblings = element.parentNode?.childNodes || []; - for (let sibling of siblings) { - if (sibling === element) { - return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']'; - } - if (sibling.nodeType === 1 && sibling.tagName === element.tagName) { - ix++; - } - - } - return ''; - } - /** - * Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state + * Responsible for returning the class to show an overlay or not + * @param side + * @returns */ - turnOffIncognito() { - this.incognitoMode = false; - const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); - window.history.replaceState({}, '', newRoute); - this.toastr.info('Incognito mode is off. Progress will now start being tracked.'); - this.saveProgress(); + clickOverlayClass(side: 'right' | 'left') { + // TODO: See if we can use RXjs or a component to manage this aka an observable that emits the highlight to show at any given time + if (!this.clickToPaginateVisualOverlay) { + return ''; + } + + if (this.readingDirection === ReadingDirection.LeftToRight) { + return side === 'right' ? 'highlight' : 'highlight-2'; + } + return side === 'right' ? 'highlight-2' : 'highlight'; } - toggleFullscreen() { - this.isFullscreen = this.readerService.checkFullscreenMode(); - if (this.isFullscreen) { - this.readerService.exitFullscreen(() => { - this.isFullscreen = false; - this.renderer.removeStyle(this.reader.nativeElement, 'background'); - }); - } else { - this.readerService.enterFullscreen(this.reader.nativeElement, () => { - this.isFullscreen = true; - // HACK: This is a bug with how browsers change the background color for fullscreen mode - if (!this.darkMode) { - this.renderer.setStyle(this.reader.nativeElement, 'background', 'white'); - } - }); + + + toggleMenu(event: MouseEvent) { + const targetElement = (event.target as Element); + const mouseOffset = 5; + + if (!this.immersiveMode) return; + if (targetElement.getAttribute('onclick') !== null || targetElement.getAttribute('href') !== null || targetElement.getAttribute('role') !== null || targetElement.getAttribute('kavita-part') != null) { + // Don't do anything, it's actionable + return; } + + if ( + Math.abs(this.mousePosition.x - event.screenX) <= mouseOffset && + Math.abs(this.mousePosition.y - event.screenY) <= mouseOffset + ) { + this.actionBarVisible = !this.actionBarVisible; + } + + } + + mouseDown($event: MouseEvent) { + this.mousePosition.x = $event.screenX; + this.mousePosition.y = $event.screenY; } } diff --git a/UI/Web/src/app/book-reader/book.service.ts b/UI/Web/src/app/book-reader/book.service.ts index bef4ce73d..10d5b6f7c 100644 --- a/UI/Web/src/app/book-reader/book.service.ts +++ b/UI/Web/src/app/book-reader/book.service.ts @@ -10,6 +10,16 @@ export interface BookPage { html: string; } +export interface FontFamily { + /** + * What the user should see + */ + title: string; + /** + * The actual font face + */ + family: string; +} @Injectable({ providedIn: 'root' @@ -20,8 +30,10 @@ export class BookService { constructor(private http: HttpClient) { } - getFontFamilies() { - return ['default', 'EBGaramond', 'Fira Sans', 'Lato', 'Libre Baskerville', 'Merriweather', 'Nanum Gothic', 'RocknRoll One']; + getFontFamilies(): Array { + return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'}, + {title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'}, + {title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}]; } getBookChapters(chapterId: number) { diff --git a/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.html b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.html new file mode 100644 index 000000000..0de05a19b --- /dev/null +++ b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.html @@ -0,0 +1,153 @@ + +
    + + + +

    + +

    +
    + +
    +
    +
    + + +
    +
    +
    + + + + + + +
    + +
    + + + 1x + + 2.5x + +
    + +
    + + + + + + +
    + +
    + +
    +
    +
    + +
    + + + +

    + +

    +
    + +
    + + +
    +
    + + Click the edges of the screen to paginate + + + +
    + + +
    +
    +
    + + This will hide the menu behind a click on the reader document and turn tap to paginate on + + + +
    + + +
    +
    + +
    + + Put reader in fullscreen mode + + + + +
    + +
    + + Default: Mirrors epub file (usually one long scrolling page per chapter).
    1 Column: Creates a single virtual page at a time.
    2 Column: Creates two virtual pages at a time laid out side-by-side.
    + + + +
    +
    + + + + + + + + +
    +
    + + +
    +
    + + + +

    + +

    +
    + +
    + + + +
    +
    +
    + +
    +
    \ No newline at end of file diff --git a/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.scss b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.scss new file mode 100644 index 000000000..f379f66c3 --- /dev/null +++ b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.scss @@ -0,0 +1,49 @@ +.controls { + margin: 0.25rem 0 0.25rem; + + .form-select { + option{ + background-color: var(--input-bg-color); + } + } + + .form-label { + margin: 0; + } + + .btn.btn-icon { + display: flex; + width: 50%; + justify-content: center; + align-items: center; + &.color { + display: unset; + width: auto; + + .dot { + height: 25px; + width: 25px; + border-radius: 50%; + margin: 0 auto; + } + } + } + + .form-check.form-switch { + width: 50%; + display: flex; + justify-content: center; + + input { + margin-right: 0.25rem; + } + } +} + +.active { + border: 1px solid var(--primary-color); +} + +::ng-deep .accordion-body { + padding: 0.25rem 1rem 1rem !important; +} diff --git a/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts new file mode 100644 index 000000000..8c4344d98 --- /dev/null +++ b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts @@ -0,0 +1,290 @@ +import { DOCUMENT } from '@angular/common'; +import { Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { Subject, take, takeUntil } from 'rxjs'; +import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode'; +import { BookTheme } from 'src/app/_models/preferences/book-theme'; +import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; +import { ThemeProvider } from 'src/app/_models/preferences/site-theme'; +import { User } from 'src/app/_models/user'; +import { AccountService } from 'src/app/_services/account.service'; +import { ThemeService } from 'src/app/_services/theme.service'; +import { BookService, FontFamily } from '../book.service'; +import { BookBlackTheme } from '../_models/book-black-theme'; +import { BookDarkTheme } from '../_models/book-dark-theme'; +import { BookWhiteTheme } from '../_models/book-white-theme'; + +/** + * Used for book reader. Do not use for other components + */ +export interface PageStyle { + 'font-family': string; + 'font-size': string; + 'line-height': string; + 'margin-left': string; + 'margin-right': string; +} + +export const bookColorThemes = [ + { + name: 'Dark', + colorHash: '#292929', + isDarkTheme: true, + isDefault: true, + provider: ThemeProvider.System, + selector: 'brtheme-dark', + content: BookDarkTheme + }, + { + name: 'Black', + colorHash: '#000000', + isDarkTheme: true, + isDefault: false, + provider: ThemeProvider.System, + selector: 'brtheme-black', + content: BookBlackTheme + }, + { + name: 'White', + colorHash: '#FFFFFF', + isDarkTheme: false, + isDefault: false, + provider: ThemeProvider.System, + selector: 'brtheme-white', + content: BookWhiteTheme + }, +]; + +const mobileBreakpointMarginOverride = 700; + +@Component({ + selector: 'app-reader-settings', + templateUrl: './reader-settings.component.html', + styleUrls: ['./reader-settings.component.scss'] +}) +export class ReaderSettingsComponent implements OnInit, OnDestroy { + /** + * Outputs when clickToPaginate is changed + */ + @Output() clickToPaginateChanged: EventEmitter = new EventEmitter(); + /** + * Outputs when a style is updated and the reader needs to render it + */ + @Output() styleUpdate: EventEmitter = new EventEmitter(); + /** + * Outputs when a theme/dark mode is updated + */ + @Output() colorThemeUpdate: EventEmitter = new EventEmitter(); + /** + * Outputs when a layout mode is updated + */ + @Output() layoutModeUpdate: EventEmitter = new EventEmitter(); + /** + * Outputs when fullscreen is toggled + */ + @Output() fullscreen: EventEmitter = new EventEmitter(); + /** + * Outputs when reading direction is changed + */ + @Output() readingDirection: EventEmitter = new EventEmitter(); + /** + * Outputs when immersive mode is changed + */ + @Output() immersiveMode: EventEmitter = new EventEmitter(); + + user!: User; + /** + * List of all font families user can select from + */ + fontOptions: Array = []; + fontFamilies: Array = []; + /** + * Internal property used to capture all the different css properties to render on all elements + */ + pageStyles!: PageStyle; + + readingDirectionModel: ReadingDirection = ReadingDirection.LeftToRight; + + activeTheme: BookTheme | undefined; + + isFullscreen: boolean = false; + + settingsForm: FormGroup = new FormGroup({}); + + /** + * System provided themes + */ + themes: Array = bookColorThemes; + + + private onDestroy: Subject = new Subject(); + + + get BookPageLayoutMode(): typeof BookPageLayoutMode { + return BookPageLayoutMode; + } + + get ReadingDirection() { + return ReadingDirection; + } + + + + constructor(private bookService: BookService, private accountService: AccountService, + @Inject(DOCUMENT) private document: Document, private themeService: ThemeService) {} + + ngOnInit(): void { + + this.fontFamilies = this.bookService.getFontFamilies(); + this.fontOptions = this.fontFamilies.map(f => f.title); + + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + this.user = user; + + if (this.user.preferences.bookReaderFontFamily === undefined) { + this.user.preferences.bookReaderFontFamily = 'default'; + } + if (this.user.preferences.bookReaderFontSize === undefined || this.user.preferences.bookReaderFontSize < 50) { + this.user.preferences.bookReaderFontSize = 100; + } + if (this.user.preferences.bookReaderLineSpacing === undefined || this.user.preferences.bookReaderLineSpacing < 100) { + this.user.preferences.bookReaderLineSpacing = 100; + } + if (this.user.preferences.bookReaderMargin === undefined) { + this.user.preferences.bookReaderMargin = 0; + } + if (this.user.preferences.bookReaderReadingDirection === undefined) { + this.user.preferences.bookReaderReadingDirection = ReadingDirection.LeftToRight; + } + this.readingDirectionModel = this.user.preferences.bookReaderReadingDirection; + + + this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); + this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(fontName => { + const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family; + if (familyName === 'default') { + this.pageStyles['font-family'] = 'inherit'; + } else { + this.pageStyles['font-family'] = "'" + familyName + "'"; + } + + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, [])); + this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(value => { + this.pageStyles['font-size'] = value + '%'; + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.user.preferences.bookReaderTapToPaginate, [])); + this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(value => { + this.clickToPaginateChanged.emit(value); + }); + + this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.user.preferences.bookReaderLineSpacing, [])); + this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(value => { + this.pageStyles['line-height'] = value + '%'; + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, [])); + this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(value => { + this.pageStyles['margin-left'] = value + '%'; + this.pageStyles['margin-right'] = value + '%'; + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, [])); + this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((layoutMode: BookPageLayoutMode) => { + this.layoutModeUpdate.emit(layoutMode); + }); + + this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.user.preferences.bookReaderImmersiveMode, [])); + this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((immersiveMode: boolean) => { + if (immersiveMode) { + this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true); + } + this.immersiveMode.emit(immersiveMode); + }); + + + this.setTheme(this.user.preferences.bookReaderThemeName || this.themeService.defaultBookTheme); + + // Emit first time so book reader gets the setting + this.readingDirection.emit(this.readingDirectionModel); + this.clickToPaginateChanged.emit(this.user.preferences.bookReaderTapToPaginate); + this.layoutModeUpdate.emit(this.user.preferences.bookReaderLayoutMode); + this.immersiveMode.emit(this.user.preferences.bookReaderImmersiveMode); + + this.resetSettings(); + } else { + this.resetSettings(); + } + + + }); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + + resetSettings() { + if (this.user) { + this.setPageStyles(this.user.preferences.bookReaderFontFamily, this.user.preferences.bookReaderFontSize + '%', this.user.preferences.bookReaderMargin + '%', this.user.preferences.bookReaderLineSpacing + '%'); + } else { + this.setPageStyles(); + } + + this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily); + this.styleUpdate.emit(this.pageStyles); + } + + /** + * Internal method to be used by resetSettings. Pass items in with quantifiers + */ + setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string, colorTheme?: string) { + const windowWidth = window.innerWidth + || this.document.documentElement.clientWidth + || this.document.body.clientWidth; + + + let defaultMargin = '15%'; + if (windowWidth <= mobileBreakpointMarginOverride) { + defaultMargin = '5%'; + } + this.pageStyles = { + 'font-family': fontFamily || this.pageStyles['font-family'] || 'default', + 'font-size': fontSize || this.pageStyles['font-size'] || '100%', + 'margin-left': margin || this.pageStyles['margin-left'] || defaultMargin, + 'margin-right': margin || this.pageStyles['margin-right'] || defaultMargin, + 'line-height': lineHeight || this.pageStyles['line-height'] || '100%' + }; + + } + + setTheme(themeName: string) { + const theme = this.themes.find(t => t.name === themeName); + this.activeTheme = theme; + this.colorThemeUpdate.emit(theme); + } + + toggleReadingDirection() { + if (this.readingDirectionModel === ReadingDirection.LeftToRight) { + this.readingDirectionModel = ReadingDirection.RightToLeft; + } else { + this.readingDirectionModel = ReadingDirection.LeftToRight; + } + + this.readingDirection.emit(this.readingDirectionModel); + } + + toggleFullscreen() { + this.isFullscreen = !this.isFullscreen; + this.fullscreen.emit(); + } +} diff --git a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html new file mode 100644 index 000000000..6d43689df --- /dev/null +++ b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html @@ -0,0 +1,25 @@ +
    + +
    + This book does not have Table of Contents set in the metadata or a toc file +
    +
    + +
    + + + +
    \ No newline at end of file diff --git a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.scss b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.scss new file mode 100644 index 000000000..e556f0e78 --- /dev/null +++ b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.scss @@ -0,0 +1,11 @@ +.table-of-contents li { + cursor: pointer; + + &.active { + font-weight: bold; + } +} + +.chapter-title { + padding-inline-start: 1rem; +} \ No newline at end of file diff --git a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts new file mode 100644 index 000000000..709e4d645 --- /dev/null +++ b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts @@ -0,0 +1,48 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Subject } from 'rxjs'; +import { BookChapterItem } from '../_models/book-chapter-item'; + +@Component({ + selector: 'app-table-of-contents', + templateUrl: './table-of-contents.component.html', + styleUrls: ['./table-of-contents.component.scss'] +}) +export class TableOfContentsComponent implements OnInit, OnDestroy { + + @Input() chapterId!: number; + @Input() pageNum!: number; + @Input() currentPageAnchor!: string; + @Input() chapters:Array = []; + + @Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter(); + + + + private onDestroy: Subject = new Subject(); + + + pageAnchors: {[n: string]: number } = {}; + + constructor() {} + + ngOnInit(): void { + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + cleanIdSelector(id: string) { + const tokens = id.split('/'); + if (tokens.length > 0) { + return tokens[0]; + } + return id; + } + + loadChapterPage(pageNum: number, part: string) { + this.loadChapter.emit({pageNum, part}); + } + +} diff --git a/UI/Web/src/app/bookmark/bookmark-routing.module.ts b/UI/Web/src/app/bookmark/bookmark-routing.module.ts new file mode 100644 index 000000000..66b13c29c --- /dev/null +++ b/UI/Web/src/app/bookmark/bookmark-routing.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from "@angular/core"; +import { Routes, RouterModule } from "@angular/router"; +import { AuthGuard } from "../_guards/auth.guard"; +import { BookmarksComponent } from "./bookmarks/bookmarks.component"; + +const routes: Routes = [ + {path: '**', component: BookmarksComponent, pathMatch: 'full', canActivate: [AuthGuard]}, + { + runGuardsAndResolvers: 'always', + canActivate: [AuthGuard], + children: [ + {path: '/bookmarks', component: BookmarksComponent}, + ] + } +]; + + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class BookmarkRoutingModule { } \ No newline at end of file diff --git a/UI/Web/src/app/bookmark/bookmark.module.ts b/UI/Web/src/app/bookmark/bookmark.module.ts new file mode 100644 index 000000000..40d7716e1 --- /dev/null +++ b/UI/Web/src/app/bookmark/bookmark.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BookmarkRoutingModule } from './bookmark-routing.module'; +import { BookmarksComponent } from './bookmarks/bookmarks.component'; +import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module'; + + + +@NgModule({ + declarations: [ + BookmarksComponent + ], + imports: [ + CommonModule, + + SharedSideNavCardsModule, + + BookmarkRoutingModule + ] +}) +export class BookmarkModule { } diff --git a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.html b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.html new file mode 100644 index 000000000..42151ba2b --- /dev/null +++ b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.html @@ -0,0 +1,22 @@ + +

    + Bookmarks +

    +
    {{series?.length}} Series
    +
    + + + + + + + + There are no bookmarks. Try creating one . + + \ No newline at end of file diff --git a/API/DTOs/SeriesFilterDto.cs b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.scss similarity index 100% rename from API/DTOs/SeriesFilterDto.cs rename to UI/Web/src/app/bookmark/bookmarks/bookmarks.component.scss diff --git a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts new file mode 100644 index 000000000..d8c939cc1 --- /dev/null +++ b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts @@ -0,0 +1,168 @@ +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { take, takeWhile, finalize, Subject, forkJoin } from 'rxjs'; +import { BulkSelectionService } from 'src/app/cards/bulk-selection.service'; +import { ConfirmService } from 'src/app/shared/confirm.service'; +import { DownloadService } from 'src/app/shared/_services/download.service'; +import { KEY_CODES } from 'src/app/shared/_services/utility.service'; +import { PageBookmark } from 'src/app/_models/page-bookmark'; +import { Series } from 'src/app/_models/series'; +import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; +import { ImageService } from 'src/app/_services/image.service'; +import { ReaderService } from 'src/app/_services/reader.service'; +import { SeriesService } from 'src/app/_services/series.service'; + +@Component({ + selector: 'app-bookmarks', + templateUrl: './bookmarks.component.html', + styleUrls: ['./bookmarks.component.scss'] +}) +export class BookmarksComponent implements OnInit, OnDestroy { + + bookmarks: Array = []; + series: Array = []; + loadingBookmarks: boolean = false; + seriesIds: {[id: number]: number} = {}; + downloadingSeries: {[id: number]: boolean} = {}; + clearingSeries: {[id: number]: boolean} = {}; + actions: ActionItem[] = []; + + private onDestroy: Subject = new Subject(); + + constructor(private readerService: ReaderService, private seriesService: SeriesService, + private downloadService: DownloadService, private toastr: ToastrService, + private confirmService: ConfirmService, public bulkSelectionService: BulkSelectionService, + public imageService: ImageService, private actionFactoryService: ActionFactoryService, + private router: Router) { } + + ngOnInit(): void { + this.loadBookmarks(); + + this.actions = this.actionFactoryService.getBookmarkActions(this.handleAction.bind(this)); + } + + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + @HostListener('document:keydown.shift', ['$event']) + handleKeypress(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = true; + } + } + + @HostListener('document:keyup.shift', ['$event']) + handleKeyUp(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = false; + } + } + + async handleAction(action: Action, series: Series) { + switch (action) { + case(Action.Delete): + this.clearBookmarks(series); + break; + case(Action.DownloadBookmark): + this.downloadBookmarks(series); + break; + case(Action.ViewSeries): + this.router.navigate(['library', series.libraryId, 'series', series.id]); + break; + default: + break; + } + } + + bulkActionCallback = async (action: Action, data: any) => { + const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('bookmark'); + const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); + const seriesIds = selectedSeries.map(item => item.id); + + switch (action) { + case Action.DownloadBookmark: + this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => seriesIds.includes(bmk.seriesId))).pipe( + takeWhile(val => { + return val.state != 'DONE'; + })).subscribe(() => { + this.bulkSelectionService.deselectAll(); + }); + break; + case Action.Delete: + if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for multiple series? This cannot be undone.')) { + break; + } + + forkJoin(seriesIds.map(id => this.readerService.clearBookmarks(id))).subscribe(() => { + this.toastr.success('Bookmarks have been removed'); + this.bulkSelectionService.deselectAll(); + this.loadBookmarks(); + }) + break; + default: + break; + } + } + + loadBookmarks() { + this.loadingBookmarks = true; + this.readerService.getAllBookmarks().pipe(take(1)).subscribe(bookmarks => { + this.bookmarks = bookmarks; + this.seriesIds = {}; + this.bookmarks.forEach(bmk => { + if (!this.seriesIds.hasOwnProperty(bmk.seriesId)) { + this.seriesIds[bmk.seriesId] = 1; + } else { + this.seriesIds[bmk.seriesId] += 1; + } + this.downloadingSeries[bmk.seriesId] = false; + this.clearingSeries[bmk.seriesId] = false; + }); + + const ids = Object.keys(this.seriesIds).map(k => parseInt(k, 10)); + this.seriesService.getAllSeriesByIds(ids).subscribe(series => { + this.series = series; + this.loadingBookmarks = false; + }); + }); + } + + viewBookmarks(series: Series) { + this.router.navigate(['library', series.libraryId, 'series', series.id, 'manga', 0], {queryParams: {incognitoMode: false, bookmarkMode: true}}); + } + + async clearBookmarks(series: Series) { + if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for ' + series.name + '? This cannot be undone.')) { + return; + } + + this.clearingSeries[series.id] = true; + this.readerService.clearBookmarks(series.id).subscribe(() => { + const index = this.series.indexOf(series); + if (index > -1) { + this.series.splice(index, 1); + } + this.clearingSeries[series.id] = false; + this.toastr.success(series.name + '\'s bookmarks have been removed'); + }); + } + + getBookmarkPages(seriesId: number) { + return this.seriesIds[seriesId]; + } + + downloadBookmarks(series: Series) { + this.downloadingSeries[series.id] = true; + this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => bmk.seriesId === series.id)).pipe( + takeWhile(val => { + return val.state != 'DONE'; + }), + finalize(() => { + this.downloadingSeries[series.id] = false; + })).subscribe(() => {/* No Operation */}); + } + +} diff --git a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html b/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html deleted file mode 100644 index 606ed1384..000000000 --- a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.ts b/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.ts deleted file mode 100644 index 38dd16ff7..000000000 --- a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { ToastrService } from 'ngx-toastr'; -import { finalize, take, takeWhile } from 'rxjs/operators'; -import { DownloadService } from 'src/app/shared/_services/download.service'; -import { PageBookmark } from 'src/app/_models/page-bookmark'; -import { Series } from 'src/app/_models/series'; -import { ImageService } from 'src/app/_services/image.service'; -import { ReaderService } from 'src/app/_services/reader.service'; - -@Component({ - selector: 'app-bookmarks-modal', - templateUrl: './bookmarks-modal.component.html', - styleUrls: ['./bookmarks-modal.component.scss'] -}) -export class BookmarksModalComponent implements OnInit { - - @Input() series!: Series; - - bookmarks: Array = []; - title: string = ''; - subtitle: string = ''; - isDownloading: boolean = false; - isClearing: boolean = false; - - uniqueChapters: number = 0; - - constructor(public imageService: ImageService, private readerService: ReaderService, - public modal: NgbActiveModal, private downloadService: DownloadService, - private toastr: ToastrService) { } - - ngOnInit(): void { - this.init(); - } - - init() { - this.readerService.getBookmarksForSeries(this.series.id).pipe(take(1)).subscribe(bookmarks => { - this.bookmarks = bookmarks; - const chapters: {[id: number]: string} = {}; - this.bookmarks.forEach(bmk => { - if (!chapters.hasOwnProperty(bmk.chapterId)) { - chapters[bmk.chapterId] = ''; - } - }); - this.uniqueChapters = Object.keys(chapters).length; - }); - } - - close() { - this.modal.close(); - } - - removeBookmark(bookmark: PageBookmark, index: number) { - this.bookmarks.splice(index, 1); - } - - downloadBookmarks() { - this.isDownloading = true; - this.downloadService.downloadBookmarks(this.bookmarks).pipe( - takeWhile(val => { - return val.state != 'DONE'; - }), - finalize(() => { - this.isDownloading = false; - })).subscribe(() => {/* No Operation */}); - } - - clearBookmarks() { - this.isClearing = true; - this.readerService.clearBookmarks(this.series.id).subscribe(() => { - this.isClearing = false; - this.init(); - this.toastr.success(this.series.name + '\'s bookmarks have been removed'); - }); - } - -} diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html index 8f5fd60df..0fa6d0888 100644 --- a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html @@ -30,7 +30,7 @@
    - +
    diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html index 3bab2708c..b8f8e905e 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html @@ -91,20 +91,6 @@
  • {{tabs[3].title}} - -
    - - - - - No bookmarks yet - -
    -
    -
  • - -
  • - {{tabs[4].title}}

    {{utilityService.formatChapterName(libraryType) + 's'}}

      diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts index d65215ef6..6c9b86133 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts @@ -66,9 +66,8 @@ export class CardDetailsModalComponent implements OnInit { chapterActions: ActionItem[] = []; libraryType: LibraryType = LibraryType.Manga; - bookmarks: PageBookmark[] = []; - tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Bookmarks', disabled: false}, {title: 'Info', disabled: false}]; + tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Info', disabled: false}]; active = this.tabs[0]; chapterMetadata!: ChapterMetadata; @@ -95,23 +94,11 @@ export class CardDetailsModalComponent implements OnInit { ngOnInit(): void { this.isChapter = this.utilityService.isChapter(this.data); - console.log('isChapter: ', this.isChapter); this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0]; this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id)); - let bookmarkApi; - if (this.isChapter) { - bookmarkApi = this.readerService.getBookmarks(this.chapter.id); - } else { - bookmarkApi = this.readerService.getBookmarksForVolume(this.data.id); - } - - bookmarkApi.pipe(take(1)).subscribe(bookmarks => { - this.bookmarks = bookmarks; - }); - this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => { this.chapterMetadata = metadata; @@ -241,10 +228,4 @@ export class CardDetailsModalComponent implements OnInit { this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]); } } - - removeBookmark(bookmark: PageBookmark, index: number) { - this.readerService.unbookmark(bookmark.seriesId, bookmark.volumeId, bookmark.chapterId, bookmark.page).subscribe(() => { - this.bookmarks.splice(index, 1); - }); - } } diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html index 27a3916f2..3e014b1af 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html @@ -5,16 +5,15 @@
  • -