diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..1f3b8da265 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Set line formatting for scripts + +*.sh text eol=lf diff --git a/.github/workflows/nightly-docker.yml b/.github/workflows/nightly-docker.yml new file mode 100644 index 0000000000..c42f0a5ebd --- /dev/null +++ b/.github/workflows/nightly-docker.yml @@ -0,0 +1,36 @@ +name: CI to Docker Hub + +on: + push: + branches: + - 'develop' + +jobs: + docker: + runs-on: ubuntu-latest + steps: + + - name: Check Out Repo + uses: actions/checkout@v2 + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: ./ + file: ./Dockerfile + push: true + tags: kizaing/kavita:nightly-amd64 + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} \ No newline at end of file diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index 86d7880363..6631757d6f 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -22,7 +22,8 @@ public void NameInListTest(string[] seriesInput, string[] list, bool expected) Name = seriesInput[0], LocalizedName = seriesInput[1], OriginalName = seriesInput[2], - NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Parser.Parser.Normalize(seriesInput[0]) + NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Parser.Parser.Normalize(seriesInput[0]), + Metadata = new SeriesMetadata() }; Assert.Equal(expected, series.NameInList(list)); diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index b3b09d4869..456cd1b523 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -17,7 +17,8 @@ public static Series CreateSeries(string name) SortName = name, LocalizedName = name, NormalizedName = API.Parser.Parser.Normalize(name), - Volumes = new List() + Volumes = new List(), + Metadata = new SeriesMetadata() }; } @@ -53,5 +54,25 @@ public static MangaFile CreateMangaFile(string filename, MangaFormat format, int Pages = pages }; } + + public static SeriesMetadata CreateSeriesMetadata(ICollection collectionTags) + { + return new SeriesMetadata() + { + CollectionTags = collectionTags + }; + } + + public static CollectionTag CreateCollectionTag(int id, string title, string summary, bool promoted) + { + return new CollectionTag() + { + Id = id, + NormalizedTitle = API.Parser.Parser.Normalize(title).ToUpper(), + Title = title, + Summary = summary, + Promoted = promoted + }; + } } } \ No newline at end of file diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index fa932dfebf..e091665850 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -53,7 +53,7 @@ public MangaParserTests(ITestOutputHelper testOutputHelper) [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12 [Dametrans][v2]", "0")] [InlineData("Vagabond_v03", "3")] [InlineData("Mujaki No Rakune Volume 10.cbz", "10")] - [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", "3")] + [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", "0")] [InlineData("Volume 12 - Janken Boy is Coming!.cbz", "12")] [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "20")] [InlineData("Gantz.V26.cbz", "26")] @@ -61,7 +61,9 @@ public MangaParserTests(ITestOutputHelper testOutputHelper) [InlineData("[Hidoi]_Amaenaideyo_MS_vol01_chp02.rar", "1")] [InlineData("NEEDLESS_Vol.4_-_Simeon_6_v2_[SugoiSugoi].rar", "4")] [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "1")] - + [InlineData("Sword Art Online Vol 10 - Alicization Running [Yen Press] [LuCaZ] {r2}.epub", "10")] + [InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")] + [InlineData("X-Men v1 #201 (September 2007).cbz", "1")] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename)); @@ -137,6 +139,12 @@ public void ParseVolumeTest(string filename, string expected) [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "Okusama wa Shougakusei")] [InlineData("VanDread-v01-c001[MD].zip", "VanDread")] [InlineData("Momo The Blood Taker - Chapter 027 Violent Emotion.cbz", "Momo The Blood Taker")] + [InlineData("Kiss x Sis - Ch.15 - The Angst of a 15 Year Old Boy.cbz", "Kiss x Sis")] + [InlineData("Green Worldz - Chapter 112 Final Chapter (End).cbz", "Green Worldz")] + [InlineData("Noblesse - Episode 406 (52 Pages).7z", "Noblesse")] + [InlineData("X-Men v1 #201 (September 2007).cbz", "X-Men")] + [InlineData("Kodoja #001 (March 2016)", "Kodoja")] + [InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "Boku No Kokoro No Yabai Yatsu")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); @@ -197,6 +205,13 @@ public void ParseSeriesTest(string filename, string expected) [InlineData("Kiss x Sis - Ch.00 - Let's Start from 0.cbz", "0")] [InlineData("[Hidoi]_Amaenaideyo_MS_vol01_chp02.rar", "2")] [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "3")] + [InlineData("Kiss x Sis - Ch.15 - The Angst of a 15 Year Old Boy.cbz", "15")] + [InlineData("Tomogui Kyoushitsu - Chapter 006 Game 005 - Fingernails On Right Hand (Part 002).cbz", "6")] + [InlineData("Noblesse - Episode 406 (52 Pages).7z", "406")] + [InlineData("X-Men v1 #201 (September 2007).cbz", "201")] + [InlineData("Kodoja #001 (March 2016)", "1")] + [InlineData("Noblesse - Episode 429 (74 Pages).7z", "429")] + [InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "54")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename)); @@ -225,6 +240,8 @@ public void ParseEditionTest(string input, string expected) [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", true)] [InlineData("Ani-Hina Art Collection.cbz", true)] [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", true)] + [InlineData("A Town Where You Live - Bonus Chapter.zip", true)] + [InlineData("Yuki Merry - 4-Komga Anthology", true)] public void ParseMangaSpecialTest(string input, bool expected) { Assert.Equal(expected, !string.IsNullOrEmpty(API.Parser.Parser.ParseMangaSpecial(input))); diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index 2f46c6bb2e..314c7cd119 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -121,6 +121,8 @@ public void IsEpubTest(string input, bool expected) [InlineData("18-04", 4)] [InlineData("18-04.5", 4.5)] [InlineData("40", 40)] + [InlineData("40a-040b", 0)] + [InlineData("40.1_a", 0)] public void MinimumNumberFromRangeTest(string input, float expected) { Assert.Equal(expected, MinimumNumberFromRange(input)); diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 7b7e6bc2fd..7c3c473557 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -106,14 +106,16 @@ public void FindSeriesNotOnDisk_Should_RemoveNothing_Test() Name = "Cage of Eden", LocalizedName = "Cage of Eden", OriginalName = "Cage of Eden", - NormalizedName = API.Parser.Parser.Normalize("Cage of Eden") + NormalizedName = API.Parser.Parser.Normalize("Cage of Eden"), + Metadata = new SeriesMetadata() }); existingSeries.Add(new Series() { Name = "Darker Than Black", LocalizedName = "Darker Than Black", OriginalName = "Darker Than Black", - NormalizedName = API.Parser.Parser.Normalize("Darker Than Black") + NormalizedName = API.Parser.Parser.Normalize("Darker Than Black"), + Metadata = new SeriesMetadata() }); diff --git a/API/API.csproj b/API/API.csproj index 8c96cb129d..458830ca16 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -12,6 +12,23 @@ ../favicon.ico + + + Kavita + kareadita.github.io + Copyright 2020-$([System.DateTime]::Now.ToString('yyyy')) kareadita.github.io (GNU General Public v3) + + + 0.4.1 + $(Configuration)-dev + + false + false + false + + False + + @@ -33,6 +50,7 @@ + all @@ -65,4 +83,8 @@ <_ContentIncludedByDefault Remove="logs\kavita.json" /> + + + + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 671a436b0e..8c3c05c850 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -45,9 +45,9 @@ public async Task UpdatePassword(ResetPasswordDto resetPasswordDto { _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName); var user = await _userManager.Users.SingleAsync(x => x.UserName == resetPasswordDto.UserName); - var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); - if (resetPasswordDto.UserName != User.GetUsername() && !isAdmin) return Unauthorized("You are not permitted to this operation."); + if (resetPasswordDto.UserName != User.GetUsername() && !User.IsInRole(PolicyConstants.AdminRole)) + return Unauthorized("You are not permitted to this operation."); // Validate Password foreach (var validator in _userManager.PasswordValidators) diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 01588f3f4f..a2af28ab6e 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -31,7 +31,7 @@ public BookController(ILogger logger, IBookService bookService, public async Task> GetBookInfo(int chapterId) { var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); - var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); return book.Title; } @@ -47,6 +47,7 @@ public async Task GetBookPageResources(int chapterId, [FromQuery] var bookFile = book.Content.AllFiles[key]; var content = await bookFile.ReadContentAsBytesAsync(); + Response.AddCacheHeader(content); var contentType = BookService.GetContentType(bookFile.ContentType); return File(content, contentType, $"{chapterId}-{file}"); @@ -58,7 +59,7 @@ public async Task>> GetBookChapters(in // This will return a list of mappings from ID -> pagenum. ID will be the xhtml key and pagenum will be the reading order // this is used to rewrite anchors in the book text so that we always load properly in FE var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); - var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); var mappings = await _bookService.CreateKeyToPageMappingAsync(book); var navItems = await book.GetNavigationAsync(); @@ -170,11 +171,11 @@ public async Task> GetBookPage(int chapterId, [FromQuery] i { var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); - var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); var mappings = await _bookService.CreateKeyToPageMappingAsync(book); var counter = 0; - var doc = new HtmlDocument(); + var doc = new HtmlDocument {OptionFixNestedTags = true}; var baseUrl = Request.Scheme + "://" + Request.Host + Request.PathBase + "/api/"; var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; var bookPages = await book.GetReadingOrderAsync(); @@ -186,14 +187,31 @@ public async Task> GetBookPage(int chapterId, [FromQuery] i if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return Ok(content); doc.LoadHtml(content); - var body = doc.DocumentNode.SelectSingleNode("/html/body"); - + var body = doc.DocumentNode.SelectSingleNode("//body"); + + if (body == null) + { + if (doc.ParseErrors.Any()) + { + _logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName); + foreach (var error in doc.ParseErrors) + { + _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason); + } + + return BadRequest("The file is malformed! Cannot read."); + } + _logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath); + doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("")); + body = doc.DocumentNode.SelectSingleNode("/html/body"); + } + var inlineStyles = doc.DocumentNode.SelectNodes("//style"); if (inlineStyles != null) { foreach (var inlineStyle in inlineStyles) { - var styleContent = await _bookService.ScopeStyles(inlineStyle.InnerHtml, apiBase); + var styleContent = await _bookService.ScopeStyles(inlineStyle.InnerHtml, apiBase, "", book); body.PrependChild(HtmlNode.CreateNode($"")); } } @@ -217,7 +235,8 @@ public async Task> GetBookPage(int chapterId, [FromQuery] i key = correctedKey; } - var styleContent = await _bookService.ScopeStyles(await book.Content.Css[key].ReadContentAsync(), apiBase); + + var styleContent = await _bookService.ScopeStyles(await book.Content.Css[key].ReadContentAsync(), apiBase, book.Content.Css[key].FileName, book); body.PrependChild(HtmlNode.CreateNode($"")); } } @@ -280,10 +299,19 @@ public async Task> GetBookPage(int chapterId, [FromQuery] i } } + // Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping + var htmlNode = doc.DocumentNode.SelectSingleNode("//html"); + if (htmlNode != null && htmlNode.Attributes.Contains("class")) + { + var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty; + var classes = htmlNode.Attributes["class"].Value + " " + bodyClasses; + body.Attributes.Add("class", $"{classes}"); + // I actually need the body tag itself for the classes, so i will create a div and put the body stuff there. + return Ok($"
{body.InnerHtml}
"); + } - - return Ok(body.InnerHtml); + return Ok(body.InnerHtml); } counter++; diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs new file mode 100644 index 0000000000..27455a283f --- /dev/null +++ b/API/Controllers/CollectionController.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.DTOs; +using API.Entities; +using API.Extensions; +using API.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Controllers +{ + public class CollectionController : BaseApiController + { + private readonly IUnitOfWork _unitOfWork; + private readonly UserManager _userManager; + + public CollectionController(IUnitOfWork unitOfWork, UserManager userManager) + { + _unitOfWork = unitOfWork; + _userManager = userManager; + } + + [HttpGet] + public async Task> GetAllTags() + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); + if (isAdmin) + { + return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); + } + else + { + return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(); + } + + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("search")] + public async Task> SearchTags(string queryString) + { + queryString ??= ""; + queryString = queryString.Replace(@"%", ""); + if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); + + return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update")] + public async Task UpdateTag(CollectionTagDto updatedTag) + { + var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id); + if (existingTag == null) return BadRequest("This tag does not exist"); + + existingTag.Promoted = updatedTag.Promoted; + existingTag.Title = updatedTag.Title; + existingTag.NormalizedTitle = Parser.Parser.Normalize(updatedTag.Title).ToUpper(); + + if (_unitOfWork.HasChanges()) + { + if (await _unitOfWork.Complete()) + { + return Ok("Tag updated successfully"); + } + } + else + { + return Ok("Tag updated successfully"); + } + + return BadRequest("Something went wrong, please try again"); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update-series")] + public async Task UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto) + { + var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(updateSeriesForTagDto.Tag.Id); + if (tag == null) return BadRequest("Not a valid Tag"); + tag.SeriesMetadatas ??= new List(); + + // Check if Tag has updated (Summary) + if (tag.Summary == null || !tag.Summary.Equals(updateSeriesForTagDto.Tag.Summary)) + { + tag.Summary = updateSeriesForTagDto.Tag.Summary; + _unitOfWork.CollectionTagRepository.Update(tag); + } + + foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove) + { + tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove)); + } + + + if (tag.SeriesMetadatas.Count == 0) + { + _unitOfWork.CollectionTagRepository.Remove(tag); + } + + if (_unitOfWork.HasChanges() && await _unitOfWork.Complete()) + { + return Ok("Tag updated"); + } + + + return BadRequest("Something went wrong. Please try again."); + } + + + + } +} \ No newline at end of file diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index b05f99409a..234ef2ae65 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -46,5 +46,16 @@ public async Task GetSeriesCoverImage(int seriesId) Response.AddCacheHeader(content); return File(content, "image/" + format, $"seriesId"); } + + [HttpGet("collection-cover")] + public async Task GetCollectionCoverImage(int collectionTagId) + { + var content = await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId); + if (content == null) return BadRequest("No cover image"); + const string format = "jpeg"; + + Response.AddCacheHeader(content); + return File(content, "image/" + format, $"collectionTagId"); + } } } \ No newline at end of file diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 4867be3d87..72a91f1fbc 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -172,10 +172,11 @@ public async Task> DeleteLibrary(int libraryId) var username = User.GetUsername(); _logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, username); var series = await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId); + var seriesIds = series.Select(x => x.Id).ToArray(); var chapterIds = - await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(series.Select(x => x.Id).ToArray()); - var result = await _unitOfWork.LibraryRepository.DeleteLibrary(libraryId); - + await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(seriesIds); + + var result = await _unitOfWork.LibraryRepository.DeleteLibrary(libraryId); if (result && chapterIds.Any()) { _taskScheduler.CleanupChapters(chapterIds); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 43197248ee..5a39f354a1 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Comparators; using API.DTOs; using API.Entities; using API.Extensions; @@ -19,6 +20,7 @@ public class ReaderController : BaseApiController private readonly ICacheService _cacheService; private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; + private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); public ReaderController(IDirectoryService directoryService, ICacheService cacheService, ILogger logger, IUnitOfWork unitOfWork) @@ -32,6 +34,7 @@ public ReaderController(IDirectoryService directoryService, ICacheService cacheS [HttpGet("image")] public async Task GetImage(int chapterId, int page) { + if (page < 0) return BadRequest("Page cannot be less than 0"); var chapter = await _cacheService.Ensure(chapterId); if (chapter == null) return BadRequest("There was an issue finding image file for reading"); @@ -58,13 +61,27 @@ public async Task> GetImagePath(int chapterId) } [HttpGet("get-bookmark")] - public async Task> GetBookmark(int chapterId) + public async Task> GetBookmark(int chapterId) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user.Progresses == null) return Ok(0); + var bookmark = new BookmarkDto() + { + PageNum = 0, + ChapterId = chapterId, + VolumeId = 0, + SeriesId = 0 + }; + if (user.Progresses == null) return Ok(bookmark); var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId); - return Ok(progress?.PagesRead ?? 0); + if (progress != null) + { + bookmark.SeriesId = progress.SeriesId; + bookmark.VolumeId = progress.VolumeId; + bookmark.PageNum = progress.PagesRead; + bookmark.BookScrollId = progress.BookScrollId; + } + return Ok(bookmark); } [HttpPost("mark-read")] @@ -219,6 +236,7 @@ public async Task Bookmark(BookmarkDto bookmarkDto) VolumeId = bookmarkDto.VolumeId, SeriesId = bookmarkDto.SeriesId, ChapterId = bookmarkDto.ChapterId, + BookScrollId = bookmarkDto.BookScrollId, LastModified = DateTime.Now }); } @@ -227,6 +245,7 @@ public async Task Bookmark(BookmarkDto bookmarkDto) userProgress.PagesRead = bookmarkDto.PageNum; userProgress.SeriesId = bookmarkDto.SeriesId; userProgress.VolumeId = bookmarkDto.VolumeId; + userProgress.BookScrollId = bookmarkDto.BookScrollId; userProgress.LastModified = DateTime.Now; } @@ -241,7 +260,7 @@ public async Task Bookmark(BookmarkDto bookmarkDto) } /// - /// Returns the next logical volume from the series. + /// Returns the next logical chapter from the series. /// /// /// @@ -253,10 +272,10 @@ public async Task> GetNextChapter(int seriesId, int volumeId, var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id); var currentVolume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); - - var next = false; + if (currentVolume.Number == 0) { + var next = false; foreach (var chapter in currentVolume.Chapters) { if (next) @@ -265,20 +284,44 @@ public async Task> GetNextChapter(int seriesId, int volumeId, } if (currentChapterId == chapter.Id) next = true; } + + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer), currentChapterId); + if (chapterId > 0) return Ok(chapterId); } foreach (var volume in volumes) { + if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1) + { + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer), currentChapterId); + if (chapterId > 0) return Ok(chapterId); + } + if (volume.Number == currentVolume.Number + 1) { - return Ok(volume.Chapters.FirstOrDefault()?.Id); + return Ok(volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault()?.Id); } } return Ok(-1); } + private int GetNextChapterId(IEnumerable chapters, int currentChapterId) + { + var next = false; + foreach (var chapter in chapters) + { + if (next) + { + return chapter.Id; + } + if (currentChapterId == chapter.Id) next = true; + } + + return -1; + } + /// - /// Returns the previous logical volume from the series. + /// Returns the previous logical chapter from the series. /// /// /// @@ -291,29 +334,27 @@ public async Task> GetPreviousChapter(int seriesId, int volume var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id); var currentVolume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); - var next = false; + if (currentVolume.Number == 0) { - var chapters = currentVolume.Chapters.Reverse(); - foreach (var chapter in chapters) - { - if (next) - { - return Ok(chapter.Id); - } - if (currentChapterId == chapter.Id) next = true; - } + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).Reverse(), currentChapterId); + if (chapterId > 0) return Ok(chapterId); } foreach (var volume in volumes.Reverse()) { + if (volume.Number == currentVolume.Number) + { + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).Reverse(), currentChapterId); + if (chapterId > 0) return Ok(chapterId); + } if (volume.Number == currentVolume.Number - 1) { - return Ok(volume.Chapters.LastOrDefault()?.Id); + return Ok(volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).LastOrDefault()?.Id); } } return Ok(-1); } - + } } \ No newline at end of file diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 0654f7d70a..caa55b229b 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -1,5 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using API.Data; using API.DTOs; using API.Entities; using API.Extensions; @@ -56,7 +59,7 @@ public async Task> DeleteSeries(int seriesId) var chapterIds = (await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new []{seriesId})); _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username); var result = await _unitOfWork.SeriesRepository.DeleteSeriesAsync(seriesId); - + if (result) { _taskScheduler.CleanupChapters(chapterIds); @@ -145,16 +148,27 @@ public async Task UpdateSeries(UpdateSeriesDto updateSeries) } [HttpGet("recently-added")] - public async Task>> GetRecentlyAdded(int libraryId = 0, int limit = 20) + public async Task>> GetRecentlyAdded([FromQuery] UserParams userParams, int libraryId = 0) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetRecentlyAdded(user.Id, libraryId, limit)); + var series = + await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, user.Id, userParams); + + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest("Could not get series"); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + + return Ok(series); } - + [HttpGet("in-progress")] public async Task>> GetInProgress(int libraryId = 0, int limit = 20) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Ok(Array.Empty()); return Ok(await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, limit)); } @@ -165,5 +179,94 @@ public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto) _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId); return Ok(); } + + [HttpGet("metadata")] + public async Task> GetSeriesMetadata(int seriesId) + { + var metadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId); + return Ok(metadata); + } + + [HttpPost("metadata")] + public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) + { + var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId; + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series.Metadata == null) + { + series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags + .Select(dto => DbFactory.CollectionTag(dto.Id, dto.Title, dto.Summary, dto.Promoted)).ToList()); + } + else + { + series.Metadata.CollectionTags ??= new List(); + var newTags = new List(); + + // 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.CollectionTags.ToList(); + foreach (var existing in existingTags) + { + if (updateSeriesMetadataDto.Tags.SingleOrDefault(t => t.Id == existing.Id) == null) + { + // Remove tag + series.Metadata.CollectionTags.Remove(existing); + } + } + + // At this point, all tags that aren't in dto have been removed. + foreach (var tag in updateSeriesMetadataDto.Tags) + { + var existingTag = series.Metadata.CollectionTags.SingleOrDefault(t => t.Title == tag.Title); + if (existingTag != null) + { + // Update existingTag + existingTag.Promoted = tag.Promoted; + existingTag.Title = tag.Title; + existingTag.NormalizedTitle = Parser.Parser.Normalize(tag.Title).ToUpper(); + } + else + { + // Add new tag + newTags.Add(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted)); + } + } + + foreach (var tag in newTags) + { + series.Metadata.CollectionTags.Add(tag); + } + } + + if (!_unitOfWork.HasChanges()) + { + return Ok("No changes to save"); + } + + if (await _unitOfWork.Complete()) + { + return Ok("Successfully updated"); + } + + return BadRequest("Could not update metadata"); + } + + [HttpGet("series-by-collection")] + public async Task>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var series = + await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, user.Id, userParams); + + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest("Could not get series for collection"); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + + return Ok(series); + } + + } } \ No newline at end of file diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 955013baef..10d6d3e07c 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -67,6 +67,7 @@ public async Task> UpdatePreferences(UserPrefer existingPreferences.BookReaderDarkMode = preferencesDto.BookReaderDarkMode; existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; + existingPreferences.SiteDarkMode = preferencesDto.SiteDarkMode; _unitOfWork.UserRepository.Update(existingPreferences); diff --git a/API/DTOs/BookmarkDto.cs b/API/DTOs/BookmarkDto.cs index e2a1c6c2d1..c06f6d30ac 100644 --- a/API/DTOs/BookmarkDto.cs +++ b/API/DTOs/BookmarkDto.cs @@ -2,9 +2,14 @@ { public class BookmarkDto { - public int VolumeId { get; init; } - public int ChapterId { get; init; } - public int PageNum { get; init; } - public int SeriesId { get; init; } + public int VolumeId { get; set; } + public int ChapterId { get; set; } + public int PageNum { get; set; } + public int SeriesId { get; set; } + /// + /// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position + /// on pages that combine multiple "chapters". + /// + public string BookScrollId { get; set; } } } \ No newline at end of file diff --git a/API/DTOs/CollectionTagDto.cs b/API/DTOs/CollectionTagDto.cs new file mode 100644 index 0000000000..72027e84a8 --- /dev/null +++ b/API/DTOs/CollectionTagDto.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace API.DTOs +{ + public class CollectionTagDto + { + public int Id { get; set; } + public string Title { get; set; } + public string Summary { get; set; } + public bool Promoted { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/PersonDto.cs b/API/DTOs/PersonDto.cs new file mode 100644 index 0000000000..646817c1d6 --- /dev/null +++ b/API/DTOs/PersonDto.cs @@ -0,0 +1,10 @@ +using API.Entities.Enums; + +namespace API.DTOs +{ + public class PersonDto + { + public string Name { get; set; } + public PersonRole Role { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index b3b43bf1a2..d04c2a03e3 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -7,7 +7,7 @@ public class RegisterDto [Required] public string Username { get; init; } [Required] - [StringLength(16, MinimumLength = 4)] + [StringLength(32, MinimumLength = 6)] public string Password { get; init; } public bool IsAdmin { get; init; } } diff --git a/API/DTOs/ResetPasswordDto.cs b/API/DTOs/ResetPasswordDto.cs index 535d0df2f9..4b3ee3580f 100644 --- a/API/DTOs/ResetPasswordDto.cs +++ b/API/DTOs/ResetPasswordDto.cs @@ -7,7 +7,7 @@ public class ResetPasswordDto [Required] public string UserName { get; init; } [Required] - [StringLength(16, MinimumLength = 4)] + [StringLength(32, MinimumLength = 6)] public string Password { get; init; } } } \ No newline at end of file diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs new file mode 100644 index 0000000000..47d5cbee2c --- /dev/null +++ b/API/DTOs/SeriesMetadataDto.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using API.Entities; + +namespace API.DTOs +{ + public class SeriesMetadataDto + { + public int Id { get; set; } + public ICollection Genres { get; set; } + public ICollection Tags { get; set; } + public ICollection Persons { get; set; } + public string Publisher { get; set; } + public int SeriesId { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/UpdateSeriesForTagDto.cs b/API/DTOs/UpdateSeriesForTagDto.cs new file mode 100644 index 0000000000..7439811656 --- /dev/null +++ b/API/DTOs/UpdateSeriesForTagDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace API.DTOs +{ + public class UpdateSeriesForTagDto + { + public CollectionTagDto Tag { get; init; } + public ICollection SeriesIdsToRemove { get; init; } + } +} \ No newline at end of file diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs new file mode 100644 index 0000000000..fd71526b78 --- /dev/null +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using API.Entities; + +namespace API.DTOs +{ + public class UpdateSeriesMetadataDto + { + public SeriesMetadataDto SeriesMetadata { get; set; } + public ICollection Tags { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index baf7b5d257..0d8f3ae68c 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -13,5 +13,7 @@ public class UserPreferencesDto public int BookReaderFontSize { get; set; } public string BookReaderFontFamily { get; set; } public bool BookReaderTapToPaginate { get; set; } + public ReadingDirection BookReaderReadingDirection { get; set; } + public bool SiteDarkMode { get; set; } } } \ No newline at end of file diff --git a/API/Data/CollectionTagRepository.cs b/API/Data/CollectionTagRepository.cs new file mode 100644 index 0000000000..77cfe70f27 --- /dev/null +++ b/API/Data/CollectionTagRepository.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Interfaces; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data +{ + public class CollectionTagRepository : ICollectionTagRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public CollectionTagRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Remove(CollectionTag tag) + { + _context.CollectionTag.Remove(tag); + } + + public void Update(CollectionTag tag) + { + _context.Entry(tag).State = EntityState.Modified; + } + + public async Task> GetAllTagDtosAsync() + { + return await _context.CollectionTag + .Select(c => c) + .OrderBy(c => c.NormalizedTitle) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetAllPromotedTagDtosAsync() + { + return await _context.CollectionTag + .Where(c => c.Promoted) + .OrderBy(c => c.NormalizedTitle) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task GetTagAsync(int tagId) + { + return await _context.CollectionTag + .Where(c => c.Id == tagId) + .SingleOrDefaultAsync(); + } + + public async Task GetFullTagAsync(int tagId) + { + return await _context.CollectionTag + .Where(c => c.Id == tagId) + .Include(c => c.SeriesMetadatas) + .SingleOrDefaultAsync(); + } + + public async Task> SearchTagDtosAsync(string searchQuery) + { + return await _context.CollectionTag + .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") + || EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%")) + .OrderBy(s => s.Title) + .AsNoTracking() + .OrderBy(c => c.NormalizedTitle) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public Task GetCoverImageAsync(int collectionTagId) + { + return _context.CollectionTag + .Where(c => c.Id == collectionTagId) + .Select(c => c.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } + } +} \ No newline at end of file diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 9f7437cc3a..008d96ed2d 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -29,6 +29,8 @@ public DataContext(DbContextOptions options) : base(options) public DbSet AppUserRating { get; set; } public DbSet ServerSetting { get; set; } public DbSet AppUserPreferences { get; set; } + public DbSet SeriesMetadata { get; set; } + public DbSet CollectionTag { get; set; } protected override void OnModelCreating(ModelBuilder builder) { diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index 3589fc30ee..804cd75bbf 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities; using API.Entities.Enums; using API.Parser; @@ -21,7 +22,8 @@ public static Series Series(string name) NormalizedName = Parser.Parser.Normalize(name), SortName = name, Summary = string.Empty, - Volumes = new List() + Volumes = new List(), + Metadata = SeriesMetadata(Array.Empty()) }; } @@ -50,5 +52,25 @@ public static Chapter Chapter(ParserInfo info) IsSpecial = specialTreatment, }; } + + public static SeriesMetadata SeriesMetadata(ICollection collectionTags) + { + return new SeriesMetadata() + { + CollectionTags = collectionTags + }; + } + + public static CollectionTag CollectionTag(int id, string title, string summary, bool promoted) + { + return new CollectionTag() + { + Id = id, + NormalizedTitle = API.Parser.Parser.Normalize(title).ToUpper(), + Title = title, + Summary = summary, + Promoted = promoted + }; + } } } \ No newline at end of file diff --git a/API/Data/Migrations/20210509014029_SiteDarkModePreference.Designer.cs b/API/Data/Migrations/20210509014029_SiteDarkModePreference.Designer.cs new file mode 100644 index 0000000000..a33cd08093 --- /dev/null +++ b/API/Data/Migrations/20210509014029_SiteDarkModePreference.Designer.cs @@ -0,0 +1,757 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210509014029_SiteDarkModePreference")] + partial class SiteDarkModePreference + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.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"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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.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.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"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + 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.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("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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId") + .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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + 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("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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.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.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("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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + 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.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210509014029_SiteDarkModePreference.cs b/API/Data/Migrations/20210509014029_SiteDarkModePreference.cs new file mode 100644 index 0000000000..863eea564f --- /dev/null +++ b/API/Data/Migrations/20210509014029_SiteDarkModePreference.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class SiteDarkModePreference : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SiteDarkMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SiteDarkMode", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20210519215934_CollectionTag.Designer.cs b/API/Data/Migrations/20210519215934_CollectionTag.Designer.cs new file mode 100644 index 0000000000..17c4ec3536 --- /dev/null +++ b/API/Data/Migrations/20210519215934_CollectionTag.Designer.cs @@ -0,0 +1,851 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210519215934_CollectionTag")] + partial class CollectionTag + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.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"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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.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.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"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + 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("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + 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.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("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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + 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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + 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("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.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.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + 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.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210519215934_CollectionTag.cs b/API/Data/Migrations/20210519215934_CollectionTag.cs new file mode 100644 index 0000000000..b95a3bd9bd --- /dev/null +++ b/API/Data/Migrations/20210519215934_CollectionTag.cs @@ -0,0 +1,107 @@ +using API.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class CollectionTag : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CollectionTag", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: true), + NormalizedTitle = table.Column(type: "TEXT", nullable: true), + Promoted = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CollectionTag", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SeriesMetadata", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SeriesId = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SeriesMetadata", x => x.Id); + table.ForeignKey( + name: "FK_SeriesMetadata_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CollectionTagSeriesMetadata", + columns: table => new + { + CollectionTagsId = table.Column(type: "INTEGER", nullable: false), + SeriesMetadatasId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CollectionTagSeriesMetadata", x => new { x.CollectionTagsId, x.SeriesMetadatasId }); + table.ForeignKey( + name: "FK_CollectionTagSeriesMetadata_CollectionTag_CollectionTagsId", + column: x => x.CollectionTagsId, + principalTable: "CollectionTag", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CollectionTagSeriesMetadata_SeriesMetadata_SeriesMetadatasId", + column: x => x.SeriesMetadatasId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CollectionTag_Id_Promoted", + table: "CollectionTag", + columns: new[] { "Id", "Promoted" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CollectionTagSeriesMetadata_SeriesMetadatasId", + table: "CollectionTagSeriesMetadata", + column: "SeriesMetadatasId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadata_Id_SeriesId", + table: "SeriesMetadata", + columns: new[] { "Id", "SeriesId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadata_SeriesId", + table: "SeriesMetadata", + column: "SeriesId", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CollectionTagSeriesMetadata"); + + migrationBuilder.DropTable( + name: "CollectionTag"); + + migrationBuilder.DropTable( + name: "SeriesMetadata"); + } + } +} diff --git a/API/Data/Migrations/20210528150353_CollectionCoverImage.Designer.cs b/API/Data/Migrations/20210528150353_CollectionCoverImage.Designer.cs new file mode 100644 index 0000000000..b3d4c3d4af --- /dev/null +++ b/API/Data/Migrations/20210528150353_CollectionCoverImage.Designer.cs @@ -0,0 +1,854 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210528150353_CollectionCoverImage")] + partial class CollectionCoverImage + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.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"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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.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.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"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + 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("BLOB"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + 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.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("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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + 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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + 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("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.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.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + 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.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210528150353_CollectionCoverImage.cs b/API/Data/Migrations/20210528150353_CollectionCoverImage.cs new file mode 100644 index 0000000000..a38f8cf936 --- /dev/null +++ b/API/Data/Migrations/20210528150353_CollectionCoverImage.cs @@ -0,0 +1,24 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class CollectionCoverImage : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverImage", + table: "CollectionTag", + type: "BLOB", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoverImage", + table: "CollectionTag"); + } + } +} diff --git a/API/Data/Migrations/20210530201541_CollectionSummary.Designer.cs b/API/Data/Migrations/20210530201541_CollectionSummary.Designer.cs new file mode 100644 index 0000000000..9d5507b38d --- /dev/null +++ b/API/Data/Migrations/20210530201541_CollectionSummary.Designer.cs @@ -0,0 +1,857 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210530201541_CollectionSummary")] + partial class CollectionSummary + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.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"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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.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.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"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + 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("BLOB"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .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.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("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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + 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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + 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("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.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.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + 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.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210530201541_CollectionSummary.cs b/API/Data/Migrations/20210530201541_CollectionSummary.cs new file mode 100644 index 0000000000..255ad78f34 --- /dev/null +++ b/API/Data/Migrations/20210530201541_CollectionSummary.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class CollectionSummary : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Summary", + table: "CollectionTag", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Summary", + table: "CollectionTag"); + } + } +} diff --git a/API/Data/Migrations/20210603133957_BookReadingDirectionPref.Designer.cs b/API/Data/Migrations/20210603133957_BookReadingDirectionPref.Designer.cs new file mode 100644 index 0000000000..2ef682c3cf --- /dev/null +++ b/API/Data/Migrations/20210603133957_BookReadingDirectionPref.Designer.cs @@ -0,0 +1,860 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210603133957_BookReadingDirectionPref")] + partial class BookReadingDirectionPref + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.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"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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.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.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"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + 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("BLOB"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .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.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("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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + 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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + 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("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.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.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + 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.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210603133957_BookReadingDirectionPref.cs b/API/Data/Migrations/20210603133957_BookReadingDirectionPref.cs new file mode 100644 index 0000000000..9f2d9760e3 --- /dev/null +++ b/API/Data/Migrations/20210603133957_BookReadingDirectionPref.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class BookReadingDirectionPref : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BookReaderReadingDirection", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BookReaderReadingDirection", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20210603212429_BookScrollIdProgress.Designer.cs b/API/Data/Migrations/20210603212429_BookScrollIdProgress.Designer.cs new file mode 100644 index 0000000000..01a7c07a14 --- /dev/null +++ b/API/Data/Migrations/20210603212429_BookScrollIdProgress.Designer.cs @@ -0,0 +1,863 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210603212429_BookScrollIdProgress")] + partial class BookScrollIdProgress + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.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"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + 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("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + 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.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.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"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + 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("BLOB"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .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.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("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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + 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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + 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("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.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.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + 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.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210603212429_BookScrollIdProgress.cs b/API/Data/Migrations/20210603212429_BookScrollIdProgress.cs new file mode 100644 index 0000000000..f2be301fe5 --- /dev/null +++ b/API/Data/Migrations/20210603212429_BookScrollIdProgress.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class BookScrollIdProgress : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BookScrollId", + table: "AppUserProgresses", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BookScrollId", + table: "AppUserProgresses"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 04e65cfc92..f14402ecee 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -142,6 +142,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("BookReaderMargin") .HasColumnType("INTEGER"); + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + b.Property("BookReaderTapToPaginate") .HasColumnType("INTEGER"); @@ -154,6 +157,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ScalingOption") .HasColumnType("INTEGER"); + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("AppUserId") @@ -171,6 +177,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AppUserId") .HasColumnType("INTEGER"); + b.Property("BookScrollId") + .HasColumnType("TEXT"); + b.Property("ChapterId") .HasColumnType("INTEGER"); @@ -276,6 +285,39 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Chapter"); }); + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .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") @@ -401,6 +443,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Series"); }); + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + modelBuilder.Entity("API.Entities.ServerSetting", b => { b.Property("Key") @@ -467,6 +533,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AppUserLibrary"); }); + 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("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") @@ -647,6 +728,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Library"); }); + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + modelBuilder.Entity("API.Entities.Volume", b => { b.HasOne("API.Entities.Series", "Series") @@ -673,6 +765,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("API.Entities.AppRole", null) @@ -739,6 +846,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("API.Entities.Series", b => { + b.Navigation("Metadata"); + b.Navigation("Volumes"); }); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index ad0c092365..2dfeb1c0af 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -7,6 +7,7 @@ using API.Entities.Enums; using API.Services; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; namespace API.Data { @@ -55,5 +56,21 @@ public static async Task SeedSettings(DataContext context) await context.SaveChangesAsync(); } + + public static async Task SeedSeriesMetadata(DataContext context) + { + await context.Database.EnsureCreatedAsync(); + + context.Database.EnsureCreated(); + var series = await context.Series + .Include(s => s.Metadata).ToListAsync(); + + foreach (var s in series) + { + s.Metadata ??= new SeriesMetadata(); + } + + await context.SaveChangesAsync(); + } } } \ No newline at end of file diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index e4a715f113..c6575126be 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -199,6 +199,8 @@ public async Task GetSeriesByIdAsync(int seriesId) { return await _context.Series .Include(s => s.Volumes) + .Include(s => s.Metadata) + .ThenInclude(m => m.CollectionTags) .Where(s => s.Id == seriesId) .SingleOrDefaultAsync(); } @@ -289,7 +291,7 @@ private async Task AddVolumeModifiers(int userId, List volumes) /// Library to restrict to, if 0, will apply to all libraries /// How many series to pick. /// - public async Task> GetRecentlyAdded(int userId, int libraryId, int limit) + public async Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams) { if (libraryId == 0) { @@ -299,25 +301,25 @@ public async Task> GetRecentlyAdded(int userId, int libra .AsNoTracking() .Select(library => library.Id) .ToList(); - - return await _context.Series + + var allQuery = _context.Series .Where(s => userLibraries.Contains(s.LibraryId)) .AsNoTracking() .OrderByDescending(s => s.Created) - .Take(limit) .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); + .AsNoTracking(); + + return await PagedList.CreateAsync(allQuery, userParams.PageNumber, userParams.PageSize); } - return await _context.Series + var query = _context.Series .Where(s => s.LibraryId == libraryId) .AsNoTracking() .OrderByDescending(s => s.Created) - .Take(limit) .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - - + .AsNoTracking(); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } /// @@ -366,5 +368,48 @@ public async Task> GetInProgress(int userId, int libraryI return retSeries.DistinctBy(s => s.Name).Take(limit); } + + public async Task GetSeriesMetadata(int seriesId) + { + var metadataDto = await _context.SeriesMetadata + .Where(metadata => metadata.SeriesId == seriesId) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + + if (metadataDto != null) + { + metadataDto.Tags = await _context.CollectionTag + .Include(t => t.SeriesMetadatas) + .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId)) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .ToListAsync(); + } + + return metadataDto; + } + + public async Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams) + { + var userLibraries = _context.Library + .Include(l => l.AppUsers) + .Where(library => library.AppUsers.Any(user => user.Id == userId)) + .AsNoTracking() + .Select(library => library.Id) + .ToList(); + + var query = _context.CollectionTag + .Where(s => s.Id == collectionId) + .Include(c => c.SeriesMetadatas) + .ThenInclude(m => m.Series) + .SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId))) + .OrderBy(s => s.LibraryId) + .ThenBy(s => s.SortName) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking(); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } } } \ No newline at end of file diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index caa97523f4..178136e3a2 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -28,6 +28,7 @@ public UnitOfWork(DataContext context, IMapper mapper, UserManager user public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context); + public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); public async Task Complete() { diff --git a/API/Dockerfile b/API/Dockerfile index d813139f81..4289aaa3da 100644 --- a/API/Dockerfile +++ b/API/Dockerfile @@ -1,20 +1,40 @@ -FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base -WORKDIR /app -EXPOSE 80 -EXPOSE 443 - -FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build -WORKDIR /src -COPY ["API/API.csproj", "API/"] -RUN dotnet restore "API/API.csproj" -COPY . . -WORKDIR "/src/API" -RUN dotnet build "API.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "API.csproj" -c Release -o /app/publish - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "API.dll"] +#This Dockerfile pulls the latest git commit and builds Kavita from source +FROM mcr.microsoft.com/dotnet/sdk:5.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"] \ No newline at end of file diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 4bc9c71a35..fb5fe9bc27 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -42,6 +42,14 @@ public class AppUserPreferences /// Book Reader Option: Allows tapping on side of screens to paginate /// public bool BookReaderTapToPaginate { get; set; } = false; + /// + /// Book Reader Option: What direction should the next/prev page buttons go + /// + public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// UI Site Global Setting: Whether the UI should render in Dark mode or not. + /// + public bool SiteDarkMode { get; set; } diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index 65dd432964..3443a68d12 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -14,6 +14,11 @@ public class AppUserProgress : IEntityDate public int VolumeId { get; set; } public int SeriesId { get; set; } public int ChapterId { get; set; } + /// + /// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point + /// on next load + /// + public string BookScrollId { get; set; } // Relationships public AppUser AppUser { get; set; } diff --git a/API/Entities/CollectionTag.cs b/API/Entities/CollectionTag.cs new file mode 100644 index 0000000000..685b708417 --- /dev/null +++ b/API/Entities/CollectionTag.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities +{ + /// + /// Represents a user entered field that is used as a tagging and grouping mechanism + /// + [Index(nameof(Id), nameof(Promoted), IsUnique = true)] + public class CollectionTag : IHasConcurrencyToken + { + public int Id { get; set; } + /// + /// Visible title of the Tag + /// + public string Title { get; set; } + + /// + /// Cover Image for the collection tag + /// + public byte[] CoverImage { get; set; } + + /// + /// A description of the tag + /// + public string Summary { get; set; } + + /// + /// A normalized string used to check if the tag already exists in the DB + /// + public string NormalizedTitle { get; set; } + /// + /// A promoted collection tag will allow all linked seriesMetadata's Series to show for all users. + /// + public bool Promoted { get; set; } + + public ICollection SeriesMetadatas { get; set; } + + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + } +} \ No newline at end of file diff --git a/API/Entities/Enums/LibraryType.cs b/API/Entities/Enums/LibraryType.cs index f1a7d0fd60..ea91e16f3c 100644 --- a/API/Entities/Enums/LibraryType.cs +++ b/API/Entities/Enums/LibraryType.cs @@ -9,8 +9,6 @@ public enum LibraryType [Description("Comic")] Comic = 1, [Description("Book")] - Book = 2, - [Description("Webtoon")] - Webtoon = 3 + Book = 2 } } \ No newline at end of file diff --git a/API/Entities/Enums/PersonRole.cs b/API/Entities/Enums/PersonRole.cs new file mode 100644 index 0000000000..47e60721b1 --- /dev/null +++ b/API/Entities/Enums/PersonRole.cs @@ -0,0 +1,19 @@ +namespace API.Entities.Enums +{ + public enum PersonRole + { + /// + /// Another role, not covered by other types + /// + Other = 0, + /// + /// Author + /// + Author = 1, + /// + /// Artist + /// + Artist = 2, + + } +} \ No newline at end of file diff --git a/API/Entities/Genre.cs b/API/Entities/Genre.cs new file mode 100644 index 0000000000..743c2b7933 --- /dev/null +++ b/API/Entities/Genre.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; + +namespace API.Entities +{ + public class Genre : IHasConcurrencyToken + { + public int Id { get; set; } + public string Name { get; set; } + // TODO: MetadataUpdate add ProviderId + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + } +} \ No newline at end of file diff --git a/API/Entities/Person.cs b/API/Entities/Person.cs new file mode 100644 index 0000000000..750274b8ab --- /dev/null +++ b/API/Entities/Person.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using API.Entities.Enums; +using API.Entities.Interfaces; + +namespace API.Entities +{ + public class Person : IHasConcurrencyToken + { + public int Id { get; set; } + public string Name { get; set; } + public PersonRole Role { get; set; } + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + } +} \ No newline at end of file diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 0ad7c8c163..4ea8f1cf4b 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -32,7 +32,7 @@ public class Series : IEntityDate /// /// Summary information related to the Series /// - public string Summary { get; set; } + public string Summary { get; set; } // TODO: Migrate into SeriesMetdata public DateTime Created { get; set; } public DateTime LastModified { get; set; } public byte[] CoverImage { get; set; } @@ -40,6 +40,8 @@ public class Series : IEntityDate /// Sum of all Volume page counts /// public int Pages { get; set; } + + public SeriesMetadata Metadata { get; set; } // Relationships public List Volumes { get; set; } diff --git a/API/Entities/SeriesMetadata.cs b/API/Entities/SeriesMetadata.cs new file mode 100644 index 0000000000..e848c696e7 --- /dev/null +++ b/API/Entities/SeriesMetadata.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities +{ + [Index(nameof(Id), nameof(SeriesId), IsUnique = true)] + public class SeriesMetadata : IHasConcurrencyToken + { + public int Id { get; set; } + /// + /// Publisher of book or manga/comic + /// + //public string Publisher { get; set; } + + public ICollection CollectionTags { get; set; } + + // Relationship + public Series Series { get; set; } + public int SeriesId { get; set; } + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + } +} \ No newline at end of file diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index 3dacfc8546..61ece56760 100644 --- a/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Kavita.Common; namespace API.Extensions { @@ -6,7 +7,9 @@ public static class ClaimsPrincipalExtensions { public static string GetUsername(this ClaimsPrincipal user) { - return user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var userClaim = user.FindFirst(ClaimTypes.NameIdentifier); + if (userClaim == null) throw new KavitaException("User is not authenticated"); + return userClaim.Value; } } } \ No newline at end of file diff --git a/API/Extensions/DirectoryInfoExtensions.cs b/API/Extensions/DirectoryInfoExtensions.cs index e7f65dc514..c41ca9f8b8 100644 --- a/API/Extensions/DirectoryInfoExtensions.cs +++ b/API/Extensions/DirectoryInfoExtensions.cs @@ -1,4 +1,7 @@ -using System.IO; +using System; +using System.IO; +using System.Linq; +using API.Services; namespace API.Extensions { @@ -37,26 +40,32 @@ public static void Empty(this DirectoryInfo directory) /// public static void Flatten(this DirectoryInfo directory) { - FlattenDirectory(directory, directory); + var index = 0; + FlattenDirectory(directory, directory, ref index); } - private static void FlattenDirectory(DirectoryInfo root, DirectoryInfo directory) + private static void FlattenDirectory(DirectoryInfo root, DirectoryInfo directory, ref int directoryIndex) { - if (!root.FullName.Equals(directory.FullName)) // I might be able to replace this with root === directory + if (!root.FullName.Equals(directory.FullName)) { + var fileIndex = 1; foreach (var file in directory.EnumerateFiles()) { if (file.Directory == null) continue; - var newName = $"{file.Directory.Name}_{file.Name}"; + var paddedIndex = Parser.Parser.PadZeros(directoryIndex + ""); + // We need to rename the files so that after flattening, they are in the order we found them + var newName = $"{paddedIndex}_{fileIndex}.{file.Extension}"; var newPath = Path.Join(root.FullName, newName); if (!File.Exists(newPath)) file.MoveTo(newPath); - + fileIndex++; } + + directoryIndex++; } foreach (var subDirectory in directory.EnumerateDirectories()) { - FlattenDirectory(root, subDirectory); + FlattenDirectory(root, subDirectory, ref directoryIndex); } } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 328a27aded..33f2c2223f 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -20,6 +20,12 @@ public AutoMapperProfiles() CreateMap(); CreateMap(); + + CreateMap(); + + CreateMap(); + + CreateMap(); CreateMap(); diff --git a/API/Helpers/UserParams.cs b/API/Helpers/UserParams.cs index 344738f6d4..298719314c 100644 --- a/API/Helpers/UserParams.cs +++ b/API/Helpers/UserParams.cs @@ -4,7 +4,7 @@ public class UserParams { private const int MaxPageSize = 50; public int PageNumber { get; set; } = 1; - private int _pageSize = 10; + private int _pageSize = 30; public int PageSize { diff --git a/API/Interfaces/IBookService.cs b/API/Interfaces/IBookService.cs index f0b5a88265..297bef3aaf 100644 --- a/API/Interfaces/IBookService.cs +++ b/API/Interfaces/IBookService.cs @@ -10,13 +10,16 @@ public interface IBookService int GetNumberOfPages(string filePath); byte[] GetCoverImage(string fileFilePath, bool createThumbnail = true); Task> CreateKeyToPageMappingAsync(EpubBookRef book); + /// /// Scopes styles to .reading-section and replaces img src to the passed apiBase /// /// /// + /// If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath. + /// Book Reference, needed for if you expect Import statements /// - Task ScopeStyles(string stylesheetHtml, string apiBase); + Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book); string GetSummaryInfo(string filePath); ParserInfo ParseInfo(string filePath); } diff --git a/API/Interfaces/ICollectionTagRepository.cs b/API/Interfaces/ICollectionTagRepository.cs new file mode 100644 index 0000000000..5d820d8c25 --- /dev/null +++ b/API/Interfaces/ICollectionTagRepository.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; + +namespace API.Interfaces +{ + public interface ICollectionTagRepository + { + void Remove(CollectionTag tag); + Task> GetAllTagDtosAsync(); + Task> SearchTagDtosAsync(string searchQuery); + Task GetCoverImageAsync(int collectionTagId); + Task> GetAllPromotedTagDtosAsync(); + Task GetTagAsync(int tagId); + Task GetFullTagAsync(int tagId); + void Update(CollectionTag tag); + } +} \ No newline at end of file diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index eff8e7c080..0b89d16b6c 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -58,6 +58,8 @@ public interface ISeriesRepository Task GetVolumeCoverImageAsync(int volumeId); Task GetSeriesCoverImageAsync(int seriesId); Task> GetInProgress(int userId, int libraryId, int limit); - Task> GetRecentlyAdded(int userId, int libraryId, int limit); + Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams); + Task GetSeriesMetadata(int seriesId); + Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); } } \ No newline at end of file diff --git a/API/Interfaces/IUnitOfWork.cs b/API/Interfaces/IUnitOfWork.cs index fb81313eb9..8f4b53c8ff 100644 --- a/API/Interfaces/IUnitOfWork.cs +++ b/API/Interfaces/IUnitOfWork.cs @@ -10,6 +10,7 @@ public interface IUnitOfWork IVolumeRepository VolumeRepository { get; } ISettingsRepository SettingsRepository { get; } IAppUserProgressRepository AppUserProgressRepository { get; } + ICollectionTagRepository CollectionTagRepository { get; } Task Complete(); bool HasChanges(); } diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 29d5849543..e5c9226b31 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -9,10 +9,11 @@ namespace API.Parser { public static class Parser { - public static readonly string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip"; + public static readonly string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|.cb7"; public static readonly string BookFileExtensions = @"\.epub"; public static readonly string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg)"; public static readonly Regex FontSrcUrlRegex = new Regex("(src:url\\(\"?'?)([a-z0-9/\\._]+)(\"?'?\\))", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s[\"|'])(?[\\w\\d/\\._-]+)([\"|'];?)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly string XmlRegexExtensions = @"\.xml"; private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, RegexOptions.IgnoreCase | RegexOptions.Compiled); @@ -52,11 +53,6 @@ public static class Parser new Regex( @"(?.*)(\b|_|)(S(?\d+))", RegexOptions.IgnoreCase | RegexOptions.Compiled), - // Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz - new Regex( - @"(?.*)( |_|-)(?:Episode)(?: |_)(?\d+(-\d+)?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - }; private static readonly Regex[] MangaSeriesRegex = new[] @@ -88,11 +84,11 @@ public static class Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), //Tonikaku Cawaii [Volume 11], Darling in the FranXX - Volume 01.cbz new Regex( - @"(?.*)(?: _|-|\[|\() ?v", + @"(?.*)(?: _|-|\[|\()\s?vol(ume)?", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Momo The Blood Taker - Chapter 027 Violent Emotion.cbz new Regex( - @"(?.*) (\b|_|-)(?:chapter)", + @"(?.*)(\b|_|-|\s)(?:chapter)(\b|_|-|\s)\d", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Historys Strongest Disciple Kenichi_v11_c90-98.zip, Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) new Regex( @@ -101,7 +97,7 @@ public static class Parser //Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip must be before [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip // due to duplicate version identifiers in file. new Regex( - @"(?.*)(v|s)\d+(-\d+)?(_| )", + @"(?.*)(v|s)\d+(-\d+)?(_|\s)", RegexOptions.IgnoreCase | RegexOptions.Compiled), //[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip new Regex( @@ -115,13 +111,17 @@ public static class Parser new Regex( @"(?.*) (?\d+(?:.\d+|-\d+)?) \(\d{4}\)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Noblesse - Episode 429 (74 Pages).7z + new Regex( + @"(?.*)(\s|_)(?:Episode|Ep\.?)(\s|_)(?\d+(?:.\d+|-\d+)?)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), // Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ) new Regex( @"(?.*)\(\d", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Tonikaku Kawaii (Ch 59-67) (Ongoing) new Regex( - @"(?.*)( |_)\((c |ch |chapter )", + @"(?.*)(\s|_)\((c\s|ch\s|chapter\s)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Black Bullet (This is very loose, keep towards bottom) new Regex( @@ -148,10 +148,7 @@ public static class Parser new Regex( @"^(?!Vol\.?)(?.*)( |_|-)(?.*)( |_|-)(?.*)ch\d+-?\d?", @@ -164,6 +161,14 @@ public static class Parser new Regex( @"^(?!Vol)(?.*)(-)\d+-?\d*", // This catches a lot of stuff ^(?!Vol)(?.*)( |_)(\d+) RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Kodoja #001 (March 2016) + new Regex( + @"(?.*)(\s|_|-)#", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Baketeriya ch01-05.zip, Akiiro Bousou Biyori - 01.jpg, Beelzebub_172_RHS.zip, Cynthia the Mission 29.rar + new Regex( + @"^(?!Vol\.?)(?.*)( |_|-)(?.*)( |_|-)(ch?)\d+", @@ -292,7 +297,7 @@ public static class Parser { // Historys Strongest Disciple Kenichi_v11_c90-98.zip, ...c90.5-100.5 new Regex( - @"(c|ch)(\.? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)", + @"(\b|_)(c|ch)(\.?\s?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip new Regex( @@ -302,7 +307,10 @@ public static class Parser new Regex( @"^(?.*)(?: |_)#(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), - + // Green Worldz - Chapter 027 + new Regex( + @"^(?!Vol)(?.*)\s?(?\d+(?:.\d+|-\d+)?)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz new Regex( @"^(?!Vol)(?.*) (?\d+(?:.\d+|-\d+)?)(?: \(\d{4}\))?(\b|_|-)", @@ -364,7 +372,7 @@ public static class Parser { // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. new Regex( - @"(?Specials?|OneShot|One\-Shot|Omake|Extra( Chapter)?|Art Collection|Side( |_)Stories)", + @"(?Specials?|OneShot|One\-Shot|Omake|Extra( Chapter)?|Art Collection|Side( |_)Stories|(?>(); + var logger = services.GetRequiredService >(); logger.LogError(ex, "An error occurred during migration"); } @@ -55,6 +84,56 @@ private static IHostBuilder CreateHostBuilder(string[] args) => options.Protocols = HttpProtocols.Http1AndHttp2; }); }); + + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + if (environment != Environments.Development) + { + webBuilder.UseSentry(options => + { + options.Dsn = "https://40f4e7b49c094172a6f99d61efb2740f@o641015.ingest.sentry.io/5757423"; + options.MaxBreadcrumbs = 200; + options.AttachStacktrace = true; + options.Debug = false; + options.SendDefaultPii = false; + options.DiagnosticLevel = SentryLevel.Debug; + options.ShutdownTimeout = TimeSpan.FromSeconds(5); + options.Release = BuildInfo.Version.ToString(); + options.AddExceptionFilterForType(); + options.AddExceptionFilterForType(); + options.AddExceptionFilterForType(); + options.AddExceptionFilterForType(); + + options.BeforeSend = sentryEvent => + { + if (sentryEvent.Exception != null + && sentryEvent.Exception.Message.Contains("[GetCoverImage] This archive cannot be read:") + && sentryEvent.Exception.Message.Contains("[BookService] ")) + { + return null; // Don't send this event to Sentry + } + + sentryEvent.ServerName = null; // Never send Server Name to Sentry + return sentryEvent; + }; + + options.ConfigureScope(scope => + { + scope.User = new User() + { + Id = HashUtil.AnonymousToken() + }; + scope.Contexts.App.Name = BuildInfo.AppName; + scope.Contexts.App.Version = BuildInfo.Version.ToString(); + scope.Contexts.App.StartTime = DateTime.UtcNow; + scope.Contexts.App.Hash = HashUtil.AnonymousToken(); + scope.Contexts.App.Build = BuildInfo.Release; + scope.SetTag("culture", Thread.CurrentThread.CurrentCulture.Name); + scope.SetTag("branch", BuildInfo.Branch); + }); + + }); + } + webBuilder.UseStartup(); }); } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index dc490844cc..9adb19c0cf 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -91,16 +91,16 @@ public int GetNumberOfPagesFromArchive(string archivePath) && Parser.Parser.IsImage(entry.Key)); } case ArchiveLibrary.NotSupported: - _logger.LogError("[GetNumberOfPagesFromArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath); + _logger.LogWarning("[GetNumberOfPagesFromArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath); return 0; default: - _logger.LogError("[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); + _logger.LogWarning("[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); return 0; } } catch (Exception ex) { - _logger.LogError(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); + _logger.LogWarning(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); return 0; } } @@ -180,16 +180,16 @@ public byte[] GetCoverImage(string archivePath, bool createThumbnail = false) return createThumbnail ? CreateThumbnail(entry.Key, ms, Path.GetExtension(entry.Key)) : ms.ToArray(); } case ArchiveLibrary.NotSupported: - _logger.LogError("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); + _logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); return Array.Empty(); default: - _logger.LogError("[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); + _logger.LogWarning("[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); return Array.Empty(); } } catch (Exception ex) { - _logger.LogError(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); + _logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); } return Array.Empty(); @@ -230,7 +230,7 @@ private byte[] CreateThumbnail(string entryName, Stream stream, string formatExt } catch (Exception ex) { - _logger.LogError(ex, "There was a critical error and prevented thumbnail generation on {EntryName}. Defaulting to no cover image", entryName); + _logger.LogWarning(ex, "There was an error and prevented thumbnail generation on {EntryName}. Defaulting to no cover image", entryName); } return Array.Empty(); @@ -313,10 +313,10 @@ public string GetSummaryInfo(string archivePath) break; } case ArchiveLibrary.NotSupported: - _logger.LogError("[GetSummaryInfo] This archive cannot be read: {ArchivePath}", archivePath); + _logger.LogWarning("[GetSummaryInfo] This archive cannot be read: {ArchivePath}", archivePath); return summary; default: - _logger.LogError("[GetSummaryInfo] There was an exception when reading archive stream: {ArchivePath}", archivePath); + _logger.LogWarning("[GetSummaryInfo] There was an exception when reading archive stream: {ArchivePath}", archivePath); return summary; } @@ -327,7 +327,7 @@ public string GetSummaryInfo(string archivePath) } catch (Exception ex) { - _logger.LogError(ex, "[GetSummaryInfo] There was an exception when reading archive stream: {Filepath}", archivePath); + _logger.LogWarning(ex, "[GetSummaryInfo] There was an exception when reading archive stream: {Filepath}", archivePath); } return summary; @@ -340,7 +340,7 @@ private static void ExtractArchiveEntities(IEnumerable entries, s { entry.WriteToDirectory(extractPath, new ExtractionOptions() { - ExtractFullPath = false, + ExtractFullPath = true, // Don't flatten, let the flatterner ensure correct order of nested folders Overwrite = false }); } @@ -397,17 +397,17 @@ public void ExtractArchive(string archivePath, string extractPath) break; } case ArchiveLibrary.NotSupported: - _logger.LogError("[ExtractArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath); + _logger.LogWarning("[ExtractArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath); return; default: - _logger.LogError("[ExtractArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); + _logger.LogWarning("[ExtractArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); return; } } catch (Exception e) { - _logger.LogError(e, "There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath); + _logger.LogWarning(e, "There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath); return; } _logger.LogDebug("Extracted archive to {ExtractPath} in {ElapsedMilliseconds} milliseconds", extractPath, sw.ElapsedMilliseconds); diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 2dfbd4798b..08c4e2209b 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Web; using API.Entities.Enums; using API.Interfaces; using API.Parser; @@ -68,9 +70,11 @@ public static string GetContentType(EpubContentType type) public static void UpdateLinks(HtmlNode anchor, Dictionary mappings, int currentPage) { if (anchor.Name != "a") return; - var hrefParts = BookService.CleanContentKeys(anchor.GetAttributeValue("href", string.Empty)) + var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty)) .Split("#"); - var mappingKey = hrefParts[0]; + // Some keys get uri encoded when parsed, so replace any of those characters with original + var mappingKey = HttpUtility.UrlDecode(hrefParts[0]); + if (!mappings.ContainsKey(mappingKey)) { if (HasClickableHrefPart(anchor)) @@ -103,8 +107,33 @@ public static void UpdateLinks(HtmlNode anchor, Dictionary mappings anchor.Attributes.Add("href", "javascript:void(0)"); } - public async Task ScopeStyles(string stylesheetHtml, string apiBase) + 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 + var prepend = filename.Length > 0 ? filename.Replace(Path.GetFileName(filename), "") : string.Empty; + var importBuilder = new StringBuilder(); + foreach (Match match in Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) + { + if (!match.Success) continue; + + var importFile = match.Groups["Filename"].Value; + var key = CleanContentKeys(importFile); + if (!key.Contains(prepend)) + { + key = prepend + key; + } + if (!book.Content.AllFiles.ContainsKey(key)) continue; + + var bookFile = book.Content.AllFiles[key]; + var content = await bookFile.ReadContentAsBytesAsync(); + importBuilder.Append(Encoding.UTF8.GetString(content)); + } + + stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString()); + stylesheetHtml = + Parser.Parser.CssImportUrlRegex.Replace(stylesheetHtml, "$1" + apiBase + prepend + "$2" + "$3"); + var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml); styleContent = Parser.Parser.FontSrcUrlRegex.Replace(styleContent, "$1" + apiBase + "$2" + "$3"); @@ -130,22 +159,31 @@ public async Task ScopeStyles(string stylesheetHtml, string apiBase) public string GetSummaryInfo(string filePath) { if (!IsValidFile(filePath)) return string.Empty; - - var epubBook = EpubReader.OpenBook(filePath); - return epubBook.Schema.Package.Metadata.Description; + + try + { + using var epubBook = EpubReader.OpenBook(filePath); + return epubBook.Schema.Package.Metadata.Description; + } + catch (Exception ex) + { + _logger.LogError(ex, "[BookService] There was an exception getting summary, defaulting to empty string"); + } + + return string.Empty; } private bool IsValidFile(string filePath) { if (!File.Exists(filePath)) { - _logger.LogError("Book {EpubFile} could not be found", filePath); + _logger.LogError("[BookService] Book {EpubFile} could not be found", filePath); return false; } if (Parser.Parser.IsBook(filePath)) return true; - _logger.LogError("Book {EpubFile} is not a valid EPUB", filePath); + _logger.LogError("[BookService] Book {EpubFile} is not a valid EPUB", filePath); return false; } @@ -155,12 +193,12 @@ public int GetNumberOfPages(string filePath) try { - var epubBook = EpubReader.OpenBook(filePath); + using var epubBook = EpubReader.OpenBook(filePath); return epubBook.Content.Html.Count; } catch (Exception ex) { - _logger.LogError(ex, "There was an exception getting number of pages, defaulting to 0"); + _logger.LogError(ex, "[BookService] There was an exception getting number of pages, defaulting to 0"); } return 0; @@ -195,7 +233,7 @@ public ParserInfo ParseInfo(string filePath) { try { - var epubBook = EpubReader.OpenBook(filePath); + using var epubBook = EpubReader.OpenBook(filePath); return new ParserInfo() { @@ -212,17 +250,18 @@ public ParserInfo ParseInfo(string filePath) } catch (Exception ex) { - _logger.LogError(ex, "There was an exception when opening epub book: {FileName}", filePath); + _logger.LogError(ex, "[BookService] There was an exception when opening epub book: {FileName}", filePath); } return null; } + public byte[] GetCoverImage(string fileFilePath, bool createThumbnail = true) { if (!IsValidFile(fileFilePath)) return Array.Empty(); - var epubBook = EpubReader.OpenBook(fileFilePath); + using var epubBook = EpubReader.OpenBook(fileFilePath); try @@ -230,7 +269,7 @@ public byte[] GetCoverImage(string fileFilePath, bool createThumbnail = true) // Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one. var coverImageContent = epubBook.Content.Cover ?? epubBook.Content.Images.Values.FirstOrDefault(file => Parser.Parser.IsCoverImage(file.FileName)) - ?? epubBook.Content.Images.Values.First(); + ?? epubBook.Content.Images.Values.FirstOrDefault(); if (coverImageContent == null) return Array.Empty(); @@ -246,7 +285,7 @@ public byte[] GetCoverImage(string fileFilePath, bool createThumbnail = true) } catch (Exception ex) { - _logger.LogError(ex, "There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); + _logger.LogError(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); } return Array.Empty(); diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 4dcad4dc5c..2ce9b375b7 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -62,10 +62,11 @@ public async Task Ensure(int chapterId) } - if (fileCount > 1) - { - new DirectoryInfo(extractPath).Flatten(); - } + new DirectoryInfo(extractPath).Flatten(); + // if (fileCount > 1) + // { + // new DirectoryInfo(extractPath).Flatten(); + // } return chapter; } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 432212f6f0..e22803c4b3 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -228,6 +228,7 @@ private void UpdateLibrary(Library library, Dictionary> existingSeries.NormalizedName = Parser.Parser.Normalize(existingSeries.Name); existingSeries.OriginalName ??= infos[0].Series; + existingSeries.Metadata ??= DbFactory.SeriesMetadata(new List()); } // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series @@ -239,6 +240,7 @@ private void UpdateLibrary(Library library, Dictionary> _logger.LogInformation("Processing series {SeriesName}", series.OriginalName); UpdateVolumes(series, parsedSeries[Parser.Parser.Normalize(series.OriginalName)].ToArray()); series.Pages = series.Volumes.Sum(v => v.Pages); + // Test } catch (Exception ex) { diff --git a/API/Startup.cs b/API/Startup.cs index 4d26d933ea..82fd667a3c 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -7,6 +7,7 @@ using API.Services; using Hangfire; using Hangfire.MemoryStorage; +using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -82,6 +83,7 @@ public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJo app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1")); app.UseHangfireDashboard(); } + app.UseResponseCompression(); app.UseForwardedHeaders(); @@ -131,19 +133,21 @@ public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJo applicationLifetime.ApplicationStopping.Register(OnShutdown); applicationLifetime.ApplicationStarted.Register(() => { - Console.WriteLine("Kavita - v0.4.0"); + Console.WriteLine($"Kavita - v{BuildInfo.Version}"); }); - + // Any services that should be bootstrapped go here taskScheduler.ScheduleTasks(); } private void OnShutdown() { - Console.WriteLine("Server is shutting down. Going to dispose Hangfire"); - //this code is called when the application stops + Console.WriteLine("Server is shutting down. Please allow a few seconds to stop any background jobs..."); TaskScheduler.Client.Dispose(); System.Threading.Thread.Sleep(1000); + Console.WriteLine("You may now close the application window."); } + + } } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..7f4e8ac718 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +#This Dockerfile pulls the latest git commit and builds Kavita from source +FROM mcr.microsoft.com/dotnet/sdk:5.0-focal AS builder + +MAINTAINER Chris P + +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 + +MAINTAINER Chris P + +#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/Dockerfile.alpine b/Dockerfile.alpine new file mode 100644 index 0000000000..faacfa823f --- /dev/null +++ b/Dockerfile.alpine @@ -0,0 +1,28 @@ +#This Dockerfile is for the musl alpine build of Kavita. +FROM alpine:latest + +MAINTAINER Chris P + +#Installs the needed dependencies +RUN apk update && apk add --no-cache wget curl pwgen icu-dev bash + +#Downloads Kavita, unzips and moves the folders to where they need to be +RUN wget https://github.com/Kareadita/Kavita/releases/download/v0.3.7/kavita-linux-musl-x64.tar.gz \ + && tar -xzf kavita*.tar.gz \ + && mv Kavita/ /kavita/ \ + && rm kavita*.gz \ + && chmod +x /kavita/Kavita + +#Creates the needed folders +RUN mkdir /manga /kavita/data /kavita/temp /kavita/cache + +RUN 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/Dockerfile.arm b/Dockerfile.arm new file mode 100644 index 0000000000..e28430a382 --- /dev/null +++ b/Dockerfile.arm @@ -0,0 +1,27 @@ +#This Dockerfile pulls the latest git commit and builds Kavita from source + +#Production image +FROM ubuntu:focal + +#Move the output files to where they need to be +COPY 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 /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/INSTALL.txt b/INSTALL.txt index a8b83f9055..a7d2bd1bcf 100644 --- a/INSTALL.txt +++ b/INSTALL.txt @@ -1,5 +1,5 @@ How to Install 1. Unzip the archive to a directory that is writable. If on windows, do not place in Program Files. 2. (Linux only) Chmod and Chown so Kavita can write to the directory you placed in. -3. Open appsettings.json and modify TokenKey to a random string ideally generated from https://passwordsgenerator.net/ -4. Run Kavita executable \ No newline at end of file +3. Run Kavita executable. +4. Open localhost:5000 and setup your account and libraries in the UI. \ No newline at end of file diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs new file mode 100644 index 0000000000..02a01c9d87 --- /dev/null +++ b/Kavita.Common/Configuration.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace Kavita.Common +{ + public static class Configuration + { + + public static bool CheckIfJwtTokenSet(string filePath) + { + try { + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + const string key = "TokenKey"; + + if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) + { + return tokenElement.GetString() != "super secret unguessable key"; + } + + return false; + + } + catch (Exception ex) { + Console.WriteLine("Error writing app settings: " + ex.Message); + } + + return false; + } + + public static bool UpdateJwtToken(string filePath, string token) + { + try + { + var json = File.ReadAllText(filePath).Replace("super secret unguessable key", token); + File.WriteAllText(filePath, json); + return true; + } + catch (Exception) + { + return false; + } + } + } +} \ No newline at end of file diff --git a/Kavita.Common/EnvironmentInfo/BuildInfo.cs b/Kavita.Common/EnvironmentInfo/BuildInfo.cs new file mode 100644 index 0000000000..a1f72195cf --- /dev/null +++ b/Kavita.Common/EnvironmentInfo/BuildInfo.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace Kavita.Common.EnvironmentInfo +{ + public static class BuildInfo + { + static BuildInfo() + { + var assembly = Assembly.GetExecutingAssembly(); + + Version = assembly.GetName().Version; + + var attributes = assembly.GetCustomAttributes(true); + + Branch = "unknown"; + + var config = attributes.OfType().FirstOrDefault(); + if (config != null) + { + Branch = config.Configuration; // TODO: This is not helpful, better to have main/develop branch + } + + Release = $"{Version}-{Branch}"; + } + + public static string AppName { get; } = "Kavita"; + + public static Version Version { get; } + public static string Branch { get; } + public static string Release { get; } + + public static DateTime BuildDateTime + { + get + { + var fileLocation = Assembly.GetCallingAssembly().Location; + return new FileInfo(fileLocation).LastWriteTimeUtc; + } + } + + public static bool IsDebug + { + get + { +#if DEBUG + return true; +#else + return false; +#endif + } + } + } +} \ No newline at end of file diff --git a/Kavita.Common/EnvironmentInfo/IOsInfo.cs b/Kavita.Common/EnvironmentInfo/IOsInfo.cs new file mode 100644 index 0000000000..f93e4781c8 --- /dev/null +++ b/Kavita.Common/EnvironmentInfo/IOsInfo.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace Kavita.Common.EnvironmentInfo +{ + public class OsInfo : IOsInfo + { + public static Os Os { get; } + + public static bool IsNotWindows => !IsWindows; + public static bool IsLinux => Os == Os.Linux || Os == Os.LinuxMusl || Os == Os.Bsd; + public static bool IsOsx => Os == Os.Osx; + public static bool IsWindows => Os == Os.Windows; + + // this needs to not be static so we can mock it + public bool IsDocker { get; } + + public string Version { get; } + public string Name { get; } + public string FullName { get; } + + static OsInfo() + { + var platform = Environment.OSVersion.Platform; + + switch (platform) + { + case PlatformID.Win32NT: + { + Os = Os.Windows; + break; + } + + case PlatformID.MacOSX: + case PlatformID.Unix: + { + Os = GetPosixFlavour(); + break; + } + } + } + + public OsInfo(IEnumerable versionAdapters) + { + OsVersionModel osInfo = null; + + foreach (var osVersionAdapter in versionAdapters.Where(c => c.Enabled)) + { + try + { + osInfo = osVersionAdapter.Read(); + } + catch (Exception e) + { + Console.WriteLine("Couldn't get OS Version info: " + e.Message); + } + + if (osInfo != null) + { + break; + } + } + + if (osInfo != null) + { + Name = osInfo.Name; + Version = osInfo.Version; + FullName = osInfo.FullName; + } + else + { + Name = Os.ToString(); + FullName = Name; + } + + if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")) + { + IsDocker = true; + } + } + + private static Os GetPosixFlavour() + { + var output = RunAndCapture("uname", "-s"); + + if (output.StartsWith("Darwin")) + { + return Os.Osx; + } + else if (output.Contains("BSD")) + { + return Os.Bsd; + } + else + { +#if ISMUSL + return Os.LinuxMusl; +#else + return Os.Linux; +#endif + } + } + + private static string RunAndCapture(string filename, string args) + { + var p = new Process + { + StartInfo = + { + FileName = filename, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true + } + }; + + p.Start(); + + // To avoid deadlocks, always read the output stream first and then wait. + var output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1000); + + return output; + } + } + + public interface IOsInfo + { + string Version { get; } + string Name { get; } + string FullName { get; } + + bool IsDocker { get; } + } + + public enum Os + { + Windows, + Linux, + Osx, + LinuxMusl, + Bsd + } +} \ No newline at end of file diff --git a/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs b/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs new file mode 100644 index 0000000000..fbf4403d3c --- /dev/null +++ b/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs @@ -0,0 +1,8 @@ +namespace Kavita.Common.EnvironmentInfo +{ + public interface IOsVersionAdapter + { + bool Enabled { get; } + OsVersionModel Read(); + } +} \ No newline at end of file diff --git a/Kavita.Common/EnvironmentInfo/OsVersionModel.cs b/Kavita.Common/EnvironmentInfo/OsVersionModel.cs new file mode 100644 index 0000000000..9e91daa185 --- /dev/null +++ b/Kavita.Common/EnvironmentInfo/OsVersionModel.cs @@ -0,0 +1,27 @@ +namespace Kavita.Common.EnvironmentInfo +{ + public class OsVersionModel + { + public OsVersionModel(string name, string version, string fullName = null) + { + Name = Trim(name); + Version = Trim(version); + + if (string.IsNullOrWhiteSpace(fullName)) + { + fullName = $"{Name} {Version}"; + } + + FullName = Trim(fullName); + } + + private static string Trim(string source) + { + return source.Trim().Trim('"', '\''); + } + + public string Name { get; } + public string FullName { get; } + public string Version { get; } + } +} \ No newline at end of file diff --git a/Kavita.Common/HashUtil.cs b/Kavita.Common/HashUtil.cs new file mode 100644 index 0000000000..ff02d7d329 --- /dev/null +++ b/Kavita.Common/HashUtil.cs @@ -0,0 +1,37 @@ +using System; +using System.Text; + +namespace Kavita.Common +{ + public static class HashUtil + { + public static string CalculateCrc(string input) + { + uint mCrc = 0xffffffff; + byte[] bytes = Encoding.UTF8.GetBytes(input); + foreach (byte myByte in bytes) + { + mCrc ^= (uint)myByte << 24; + for (var i = 0; i < 8; i++) + { + if ((Convert.ToUInt32(mCrc) & 0x80000000) == 0x80000000) + { + mCrc = (mCrc << 1) ^ 0x04C11DB7; + } + else + { + mCrc <<= 1; + } + } + } + + return $"{mCrc:x8}"; + } + + public static string AnonymousToken() + { + var seed = $"{Environment.ProcessorCount}_{Environment.OSVersion.Platform}_{Environment.MachineName}_{Environment.UserName}"; + return HashUtil.CalculateCrc(seed); + } + } +} \ No newline at end of file diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj new file mode 100644 index 0000000000..43fe7f53db --- /dev/null +++ b/Kavita.Common/Kavita.Common.csproj @@ -0,0 +1,22 @@ + + + + net5.0 + kareadita.github.io + Kavita + 0.4.1.0 + en + + + + + + + + + + D:\Program Files\JetBrains\JetBrains Rider 2020.3.2\lib\ReSharperHost\TestRunner\netcoreapp2.0\JetBrains.ReSharper.TestRunner.Merged.dll + + + + diff --git a/Kavita.Common/KavitaException.cs b/Kavita.Common/KavitaException.cs new file mode 100644 index 0000000000..e91525f7e8 --- /dev/null +++ b/Kavita.Common/KavitaException.cs @@ -0,0 +1,21 @@ +using System; + +namespace Kavita.Common +{ + /// + /// These are used for errors to send to the UI that should not be reported to Sentry + /// + [Serializable] + public class KavitaException : Exception + { + public KavitaException() + { + + } + + public KavitaException(string message) : base(message) + { + + } + } +} \ No newline at end of file diff --git a/Kavita.sln b/Kavita.sln index 74927a34f4..e49484b02b 100644 --- a/Kavita.sln +++ b/Kavita.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{1 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API.Tests", "API.Tests\API.Tests.csproj", "{6F7910F2-1B95-4570-A490-519C8935B9D1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kavita.Common", "Kavita.Common\Kavita.Common.csproj", "{165A86F5-9E74-4C05-9305-A6F0BA32C9EE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,5 +46,17 @@ Global {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x64.Build.0 = Release|Any CPU {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x86.ActiveCfg = Release|Any CPU {6F7910F2-1B95-4570-A490-519C8935B9D1}.Release|x86.Build.0 = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|x64.ActiveCfg = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|x64.Build.0 = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Debug|x86.Build.0 = Debug|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|Any CPU.Build.0 = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|x64.ActiveCfg = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|x64.Build.0 = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|x86.ActiveCfg = Release|Any CPU + {165A86F5-9E74-4C05-9305-A6F0BA32C9EE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 021232415b..a3fd09193b 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,88 @@ # Kavita -![alt text](https://github.com/Kareadita/kareadita.github.io/blob/main/img/features/seriesdetail.PNG?raw=true) +
-Kavita is a fast, feature rich, cross platform OSS manga server. Built with a focus for manga, +![Cover Image](https://github.com/Kareadita/kareadita.github.io/blob/main/img/features/seriesdetail.PNG?raw=true) + +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 manga collection with your friends and family! +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) +[![License](https://img.shields.io/badge/license-GPLv3-blue.svg?style=flat)](https://github.com/Kareadita/Kavita/blob/master/LICENSE) [![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://discord.gg/eczRp9eeem) -![Github Downloads](https://img.shields.io/github/downloads/Kareadita/Kavita/total.svg) - +[![Downloads](https://img.shields.io/github/downloads/Kareadita/Kavita/total.svg?style=flat)](https://github.com/Kareadita/Kavita/releases) +[![Docker Pulls](https://img.shields.io/docker/pulls/kizaing/kavita.svg)](https://hub.docker.com/r/kizaing/kavita/) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Kareadita_Kavita&metric=alert_status)](https://sonarcloud.io/dashboard?id=Kareadita_Kavita) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Kareadita_Kavita&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=Kareadita_Kavita) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Kareadita_Kavita&metric=security_rating)](https://sonarcloud.io/dashboard?id=Kareadita_Kavita) +[![Donate via Paypal](https://img.shields.io/badge/donate-paypal-blue.svg?style=popout&logo=paypal)](https://paypal.me/majora2007?locale.x=en_US) +
## Goals: -* Serve up Manga (cbr, cbz, zip/rar, raw images) and Books (epub, mobi, azw, djvu, pdf) -* Provide Reader for Manga and Books (Light Novels) via web app that is responsive -* Provide customization themes (server installed) for web app -* Provide hooks into metadata providers to fetch Manga data -* Metadata should allow for collections, want to read integration from 3rd party services, genres. -* Ability to manage users, access, and ratings - -## How to Build +- [x] Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar, 7zip, raw images) and Books (epub, mobi, azw, djvu, pdf) +- [x] First class responsive readers that work great on any device +- [x] Provide a dark theme for web app +- [ ] Provide hooks into metadata providers to fetch metadata for Comics, Manga, and Books +- [ ] Metadata should allow for collections, want to read integration from 3rd party services, genres. +- [x] Ability to manage users, access, and ratings +- [ ] Ability to sync ratings and reviews to external services +- [x] Fully Accessible +- [ ] And so much [more...](https://github.com/Kareadita/Kavita/projects) + + +# How to contribute - Ensure you've cloned Kavita-webui. You should have Projects/Kavita and Projects/Kavita-webui - In Kavita-webui, run ng serve. This will start the webserver on localhost:4200 - Run API project in Kavita, this will start the backend on localhost:5000 -## How to Deploy +## Deploy local build - Run build.sh and pass the Runtime Identifier for your OS or just build.sh for all supported RIDs. ## How to install - Unzip the archive for your target OS - Place in a directory that is writable. If on windows, do not place in Program Files -- Open appsettings.json and modify TokenKey to a random string ideally generated from [https://passwordsgenerator.net/](https://passwordsgenerator.net/) +- Linux users must ensure the directory & kavita.db is writable by Kavita (might require starting server once) - Run Kavita - If you are updating, do not copy appsettings.json from the new version over. It will override your TokenKey and you will have to reauthenticate on your devices. ## Docker -- Docker is supported and tested, you can find the image and instructions [here](https://github.com/Kizaing/KavitaDocker). +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/data \ +--restart unless-stopped \ +-d kizaing/kavita:latest +``` + +You can also run it via the docker-compose file: + +``` +version: '3.9' +services: + kavita: + image: kizaing/kavita:latest + volumes: + - ./manga:/manga + - ./data:/kavita/data + ports: + - "5000:5000" + restart: unless-stopped +``` + +**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. There is also the :alpine tag if you want a smaller image, but it is only available for x64 systems.** + +## Got an Idea? +Got a great idea? Throw it up on the FeatHub or vote on another persons. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects) first for a list of planned features. + +[![Feature Requests](https://feathub.com/Kareadita/Kavita?format=svg)](https://feathub.com/Kareadita/Kavita) ## Want to help? -I am looking for developers with a passion for building the next Plex for Manga, Comics, and Ebooks. I need developers with C#/ASP.NET, Angular 11 or CSS experience. -Reach out to me on [Discord]((https://discord.gg/eczRp9eeem)). +I am looking for developers with a passion for building the next Plex for Reading. Developers with C#/ASP.NET, Angular 11 please reach out on [Discord](https://discord.gg/eczRp9eeem). -## Buy me a beer -I've gone through many beers building Kavita and expect to go through many more. If you want to throw me a few bucks you can [here](https://paypal.me/majora2007?locale.x=en_US). Money will go -towards beer or hosting for the upcoming Metadata release. +## 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 +likely go towards beer or hosting. +[![Donate via Paypal](https://img.shields.io/badge/donate-paypal-blue.svg?style=popout&logo=paypal)](https://paypal.me/majora2007?locale.x=en_US) diff --git a/build.sh b/build.sh index d10013968c..043cb559f3 100644 --- a/build.sh +++ b/build.sh @@ -105,6 +105,10 @@ then cd "$dir" Package "net5.0" "linux-x64" cd "$dir" + Package "net5.0" "linux-arm" + cd "$dir" + Package "net5.0" "linux-arm64" + cd "$dir" Package "net5.0" "linux-musl-x64" cd "$dir" Package "net5.0" "osx-x64" diff --git a/build_target.sh b/build_target.sh new file mode 100644 index 0000000000..56c54ba799 --- /dev/null +++ b/build_target.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +mkdir Projects + +cd Projects + +git clone https://github.com/Kareadita/Kavita.git +git clone https://github.com/Kareadita/Kavita-webui.git + +cd Kavita +chmod +x build.sh + +#Builds program based on the target platform + +if [ "$TARGETPLATFORM" == "linux/amd64" ] +then + ./build.sh linux-x64 + mv /Projects/Kavita/_output/linux-x64 /Projects/Kavita/_output/build +elif [ "$TARGETPLATFORM" == "linux/arm/v7" ] +then + ./build.sh linux-arm + mv /Projects/Kavita/_output/linux-arm /Projects/Kavita/_output/build +elif [ "$TARGETPLATFORM" == "linux/arm64" ] +then + ./build.sh linux-arm64 + mv /Projects/Kavita/_output/linux-arm64 /Projects/Kavita/_output/build +fi diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..fe479badd5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.9' +services: + kavita: + image: kizaing/kavita:latest + volumes: + - ./manga:/manga + - ./data/temp:/kavita/temp + - ./data/cache:/kavita/cache + - ./data:/kavita/data + - ./data/logs:/kavita/logs + ports: + - "5000:5000" + restart: unless-stopped diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000000..87d10d6ecc --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +#Checks if a token has been set, and then generates a new token if not +if grep -q 'super secret unguessable key' /kavita/appsettings.json +then + export TOKEN_KEY="$(pwgen -s 16 1)" + sed -i "s/super secret unguessable key/${TOKEN_KEY}/g" /kavita/appsettings.json +fi + +#Checks if the appsettings.json already exists in bind mount +if test -f "/kavita/data/appsettings.json" +then + rm /kavita/appsettings.json + ln -s /kavita/data/appsettings.json /kavita/ +else + mv /kavita/appsettings.json /kavita/data/ + ln -s /kavita/data/appsettings.json /kavita/ +fi + +#Checks if the data folders exist +if [ -d /kavita/data/temp ] +then + if [ -d /kavita/temp ] + then + unlink /kavita/temp + ln -s /kavita/data/temp /kavita/temp + else + ln -s /kavita/data/temp /kavita/temp + fi +else + mkdir /kavita/data/temp + ln -s /kavita/data/temp /kavita/temp +fi + +if [ -d /kavita/data/cache ] +then + if [ -d /kavita/cache ] + then + unlink /kavita/cache + ln -s /kavita/data/cache /kavita/cache + else + ln -s /kavita/data/cache /kavita/cache + fi +else + mkdir /kavita/data/cache + ln -s /kavita/data/cache /kavita/cache +fi + +# Checks for the log file + +if test -f "/kavita/data/logs/kavita.log" +then + rm /kavita/kavita.log + ln -s /kavita/data/logs/kavita.log /kavita/ +else + if [ -d /kavita/data/logs ] + then + touch /kavita/data/logs/kavita.log + ln -s /kavita/data/logs/kavita.log /kavita/ + else + mkdir /kavita/data/logs + touch /kavita/data/logs/kavita.log + ln -s /kavita/data/logs/kavita.log /kavita/ + fi + +fi + +./Kavita diff --git a/favicon.ico b/favicon.ico index 1ed03f4f76..cc5e735190 100644 Binary files a/favicon.ico and b/favicon.ico differ