diff --git a/API.Benchmark/ParseScannedFilesBenchmarks.cs b/API.Benchmark/ParseScannedFilesBenchmarks.cs index bc0c810ce..184f45d8c 100644 --- a/API.Benchmark/ParseScannedFilesBenchmarks.cs +++ b/API.Benchmark/ParseScannedFilesBenchmarks.cs @@ -35,6 +35,5 @@ namespace API.Benchmark var parsedSeries = _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new string[] {libraryPath}, out var totalFiles, out var scanElapsedTime); } - } } diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index d27f22dd9..939dfce35 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -66,6 +66,7 @@ namespace API.Tests.Parser [InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")] [InlineData("X-Men v1 #201 (September 2007).cbz", "1")] [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "6")] + [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "3")] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename)); @@ -128,7 +129,6 @@ namespace API.Tests.Parser [InlineData("Fullmetal Alchemist chapters 101-108.cbz", "Fullmetal Alchemist")] [InlineData("To Love Ru v09 Uncensored (Ch.071-079).cbz", "To Love Ru")] [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "One Piece - Digital Colored Comics")] - //[InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", "Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U")] [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Chapter 01", "Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U")] [InlineData("Vol03_ch15-22.rar", "")] [InlineData("Love Hina - Special.cbz", "")] // This has to be a fallback case @@ -157,6 +157,7 @@ namespace API.Tests.Parser [InlineData("Killing Bites - Vol 11 Chapter 050 Save Me, Nunupi!.cbz", "Killing Bites")] [InlineData("Mad Chimera World - Volume 005 - Chapter 026.cbz", "Mad Chimera World")] [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "Hentai Ouji to Warawanai Neko.")] + [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "The 100 Girlfriends Who Really, Really, Really, Really, Really Love You")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index d48b75707..dbae6aa09 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -2,6 +2,8 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs; +using API.DTOs.Reader; +using API.Entities.Enums; using API.Extensions; using API.Interfaces; using API.Interfaces.Services; @@ -31,12 +33,31 @@ namespace API.Controllers } [HttpGet("{chapterId}/book-info")] - public async Task> GetBookInfo(int chapterId) + public async Task> GetBookInfo(int chapterId) { + // PERF: Write this in one DB call - This does not meet NFR var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); + var volume = await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(chapter.VolumeId); + if (volume == null) return BadRequest("Could not find Volume"); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); + if (series == null) return BadRequest("Series could not be found"); - return book.Title; + var bookTitle = string.Empty; + if (series.Format == MangaFormat.Epub) + { + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); + bookTitle = book.Title; + } + + + return new BookInfoDto() + { + BookTitle = bookTitle, + VolumeId = chapter.VolumeId, + SeriesFormat = series.Format, + SeriesId = series.Id, + LibraryId = series.LibraryId, + }; } [HttpGet("{chapterId}/book-resources")] diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 53c3953ac..25f224a28 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -225,11 +225,11 @@ namespace API.Controllers [HttpGet("search")] public async Task>> Search(string queryString) { - queryString = queryString.Replace(@"%", ""); + queryString = queryString.Trim().Replace(@"%", ""); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); // Get libraries user has access to - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 438dfac77..8302c648a 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -93,6 +93,19 @@ namespace API.Controllers } }); feed.Entries.Add(new FeedEntry() + { + Id = "readingList", + Title = "Reading Lists", + Content = new FeedEntryContent() + { + Text = "Browse by Reading Lists" + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list"), + } + }); + feed.Entries.Add(new FeedEntry() { Id = "allLibraries", Title = "All Libraries", @@ -190,6 +203,7 @@ namespace API.Controllers return CreateXmlResult(SerializeXml(feed)); } + [HttpGet("{apiKey}/collections/{collectionId}")] [Produces("application/xml")] public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0) @@ -230,6 +244,76 @@ namespace API.Controllers } + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/reading-list")] + [Produces("application/xml")] + public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var user = await GetUser(apiKey); + + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(user.Id, true, new UserParams() + { + PageNumber = pageNumber + }); + + + var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey); + + foreach (var readingListDto in readingLists) + { + feed.Entries.Add(new FeedEntry() + { + Id = readingListDto.Id.ToString(), + Title = readingListDto.Title, + Summary = readingListDto.Summary, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list/{readingListDto.Id}"), + } + }); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/reading-list/{readingListId}")] + [Produces("application/xml")] + public async Task GetReadingListItems(int readingListId, string apiKey) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var user = await GetUser(apiKey); + + var userWithLists = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(user.UserName); + var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); + if (readingList == null) + { + return BadRequest("Reading list does not exist or you don't have access"); + } + + var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey); + + var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, user.Id); + foreach (var item in items) + { + feed.Entries.Add(new FeedEntry() + { + Id = item.ChapterId.ToString(), + Title = "Chapter " + item.ChapterNumber, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{item.SeriesId}/volume/{item.VolumeId}/chapter/{item.ChapterId}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={item.ChapterId}") + } + }); + } + + + return CreateXmlResult(SerializeXml(feed)); } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index c5ba062d1..2690c8bdb 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -49,7 +49,7 @@ namespace API.Controllers [HttpGet("image")] public async Task GetImage(int chapterId, int page) { - if (page < 0) return BadRequest("Page cannot be less than 0"); + if (page < 0) page = 0; var chapter = await _cacheService.Ensure(chapterId); if (chapter == null) return BadRequest("There was an issue finding image file for reading"); @@ -76,20 +76,21 @@ namespace API.Controllers /// /// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading. /// - /// + /// Not used /// /// [HttpGet("chapter-info")] public async Task> GetChapterInfo(int seriesId, int chapterId) { - // PERF: Write this in one DB call + // PERF: Write this in one DB call - This does not meet NFR var chapter = await _cacheService.Ensure(chapterId); if (chapter == null) return BadRequest("Could not find Chapter"); var volume = await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(chapter.VolumeId); if (volume == null) return BadRequest("Could not find Volume"); var mangaFile = (await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId)).First(); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); + if (series == null) return BadRequest("Series could not be found"); return Ok(new ChapterInfoDto() { @@ -97,7 +98,10 @@ namespace API.Controllers VolumeNumber = volume.Number + string.Empty, VolumeId = volume.Id, FileName = Path.GetFileName(mangaFile.FilePath), - SeriesName = series?.Name, + SeriesName = series.Name, + SeriesFormat = series.Format, + SeriesId = series.Id, + LibraryId = series.LibraryId, IsSpecial = chapter.IsSpecial, Pages = chapter.Pages, }); @@ -526,8 +530,8 @@ namespace API.Controllers [HttpGet("next-chapter")] public async Task> GetNextChapter(int seriesId, int volumeId, int currentChapterId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId); var currentVolume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); var currentChapter = await _unitOfWork.VolumeRepository.GetChapterAsync(currentChapterId); if (currentVolume.Number == 0) @@ -592,8 +596,8 @@ namespace API.Controllers [HttpGet("prev-chapter")] public async Task> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId); var currentVolume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); var currentChapter = await _unitOfWork.VolumeRepository.GetChapterAsync(currentChapterId); diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs new file mode 100644 index 000000000..73ed407b8 --- /dev/null +++ b/API/Controllers/ReadingListController.cs @@ -0,0 +1,404 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Comparators; +using API.DTOs.ReadingLists; +using API.Entities; +using API.Extensions; +using API.Helpers; +using API.Interfaces; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Controllers +{ + public class ReadingListController : BaseApiController + { + private readonly IUnitOfWork _unitOfWork; + private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + + public ReadingListController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + [HttpGet] + public async Task>> GetList(int readingListId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, userId)); + } + + /// + /// Returns reading lists (paginated) for a given user. + /// + /// Defaults to true + /// + [HttpPost("lists")] + public async Task>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted, + userParams); + Response.AddPaginationHeader(items.CurrentPage, items.PageSize, items.TotalCount, items.TotalPages); + + return Ok(items); + } + + /// + /// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress + /// + /// This call is expensive + /// + /// + [HttpGet("items")] + public async Task>> GetListForUser(int readingListId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); + + return Ok(await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList())); + } + + /// + /// Updates an items position + /// + /// + /// + [HttpPost("update-position")] + public async Task UpdateListItemPosition(UpdateReadingListPosition dto) + { + // Make sure UI buffers events + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList(); + var item = items.Find(r => r.Id == dto.ReadingListItemId); + items.Remove(item); + items.Insert(dto.ToPosition, item); + + for (var i = 0; i < items.Count; i++) + { + items[i].Order = i; + } + + if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + { + return Ok("Updated"); + } + + return BadRequest("Couldn't update position"); + } + + [HttpPost("delete-item")] + public async Task DeleteListItem(UpdateReadingListPosition dto) + { + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList(); + var item = items.Find(r => r.Id == dto.ReadingListItemId); + items.Remove(item); + + for (var i = 0; i < items.Count; i++) + { + items[i].Order = i; + } + + if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + { + return Ok("Updated"); + } + + return BadRequest("Couldn't delete item"); + } + + /// + /// Removes all entries that are fully read from the reading list + /// + /// + /// + [HttpPost("remove-read")] + public async Task DeleteReadFromList([FromQuery] int readingListId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); + items = await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList()); + + // Collect all Ids to remove + var itemIdsToRemove = items.Where(item => item.PagesRead == item.PagesTotal).Select(item => item.Id); + + try + { + var listItems = + (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).Where(r => + itemIdsToRemove.Contains(r.Id)); + _unitOfWork.ReadingListRepository.BulkRemove(listItems); + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + return Ok("Updated"); + } + else + { + return Ok("Nothing to remove"); + } + } + catch + { + await _unitOfWork.RollbackAsync(); + } + + return BadRequest("Could not remove read items"); + } + + /// + /// Deletes a reading list + /// + /// + /// + [HttpDelete] + public async Task DeleteList([FromQuery] int readingListId) + { + var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername()); + var readingList = user.ReadingLists.SingleOrDefault(r => r.Id == readingListId); + if (readingList == null) + { + return BadRequest("User is not associated with this reading list"); + } + + user.ReadingLists.Remove(readingList); + + if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + { + return Ok("Deleted"); + } + + return BadRequest("There was an issue deleting reading list"); + } + + /// + /// Creates a new List with a unique title. Returns the new ReadingList back + /// + /// + /// + [HttpPost("create")] + public async Task> CreateList(CreateReadingListDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername()); + + // When creating, we need to make sure Title is unique + var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title)); + if (hasExisting) + { + return BadRequest("A list of this name already exists"); + } + user.ReadingLists.Add(new ReadingList() + { + Promoted = false, + Title = dto.Title, + Summary = string.Empty + }); + + if (!_unitOfWork.HasChanges()) return BadRequest("There was a problem creating list"); + + await _unitOfWork.CommitAsync(); + + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(dto.Title)); + } + + [HttpPost("update")] + public async Task UpdateList(UpdateReadingListDto dto) + { + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); + if (readingList == null) return BadRequest("List does not exist"); + + if (!string.IsNullOrEmpty(dto.Title)) + { + readingList.Title = dto.Title; // Should I check if this is unique? + } + if (!string.IsNullOrEmpty(dto.Title)) + { + readingList.Summary = dto.Summary; + } + + readingList.Promoted = dto.Promoted; + + _unitOfWork.ReadingListRepository.Update(readingList); + + if (await _unitOfWork.CommitAsync()) + { + return Ok("Updated"); + } + return BadRequest("Could not update reading list"); + } + + [HttpPost("update-by-series")] + public async Task UpdateListBySeries(UpdateReadingListBySeriesDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername()); + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); + var chapterIdsForSeries = + await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId}); + + // If there are adds, tell tracking this has been modified + if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList)) + { + _unitOfWork.ReadingListRepository.Update(readingList); + } + + try + { + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + return Ok("Updated"); + } + } + catch + { + await _unitOfWork.RollbackAsync(); + } + + return Ok("Nothing to do"); + } + + [HttpPost("update-by-volume")] + public async Task UpdateListByVolume(UpdateReadingListByVolumeDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername()); + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); + var chapterIdsForVolume = + (await _unitOfWork.VolumeRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList(); + + // If there are adds, tell tracking this has been modified + if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList)) + { + _unitOfWork.ReadingListRepository.Update(readingList); + } + + try + { + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + return Ok("Updated"); + } + } + catch + { + await _unitOfWork.RollbackAsync(); + } + + return Ok("Nothing to do"); + } + + [HttpPost("update-by-chapter")] + public async Task UpdateListByChapter(UpdateReadingListByChapterDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername()); + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); + + // If there are adds, tell tracking this has been modified + if (await AddChaptersToReadingList(dto.SeriesId, new List() { dto.ChapterId }, readingList)) + { + _unitOfWork.ReadingListRepository.Update(readingList); + } + + try + { + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + return Ok("Updated"); + } + } + catch + { + await _unitOfWork.RollbackAsync(); + } + + return Ok("Nothing to do"); + } + + /// + /// Adds a list of Chapters as reading list items to the passed reading list. + /// + /// + /// + /// + /// True if new chapters were added + private async Task AddChaptersToReadingList(int seriesId, IList chapterIds, + ReadingList readingList) + { + readingList.Items ??= new List(); + var lastOrder = 0; + if (readingList.Items.Any()) + { + lastOrder = readingList.Items.DefaultIfEmpty().Max(rli => rli.Order); + } + + var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); + var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)) + .OrderBy(c => int.Parse(c.Volume.Name)) + .ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting); + + var index = lastOrder + 1; + foreach (var chapter in chaptersForSeries) + { + if (existingChapterExists.Contains(chapter.Id)) continue; + + readingList.Items.Add(new ReadingListItem() + { + Order = index, + ChapterId = chapter.Id, + SeriesId = seriesId, + VolumeId = chapter.VolumeId + }); + index += 1; + } + + return index > lastOrder + 1; + } + + /// + /// Returns the next chapter within the reading list + /// + /// + /// + /// Chapter Id for next item, -1 if nothing exists + [HttpGet("next-chapter")] + public async Task> GetNextChapter(int currentChapterId, int readingListId) + { + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList(); + var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId); + if (readingListItem == null) return BadRequest("Id does not exist"); + var index = items.IndexOf(readingListItem) + 1; + if (items.Count > index) + { + return items[index].ChapterId; + } + + return Ok(-1); + } + + /// + /// Returns the prev chapter within the reading list + /// + /// + /// + /// Chapter Id for next item, -1 if nothing exists + [HttpGet("prev-chapter")] + public async Task> GetPrevChapter(int currentChapterId, int readingListId) + { + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList(); + var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId); + if (readingListItem == null) return BadRequest("Id does not exist"); + var index = items.IndexOf(readingListItem) - 1; + if (0 <= index) + { + return items[index].ChapterId; + } + + return Ok(-1); + } + } +} diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index cce70de6d..d64c78fd3 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -32,14 +32,14 @@ namespace API.Controllers [HttpPost] public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, userParams, filterDto); + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); // 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 library"); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); @@ -55,10 +55,10 @@ namespace API.Controllers [HttpGet("{seriesId}")] public async Task> GetSeries(int seriesId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); try { - return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id)); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId)); } catch (Exception e) { @@ -95,15 +95,15 @@ namespace API.Controllers [HttpGet("volumes")] public async Task>> GetVolumes(int seriesId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id)); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId)); } [HttpGet("volume")] public async Task> GetVolume(int volumeId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, user.Id)); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, userId)); } [HttpGet("chapter")] @@ -182,14 +182,14 @@ namespace API.Controllers [HttpPost("recently-added")] public async Task>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var series = - await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, user.Id, userParams, filterDto); + await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto); // 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); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); @@ -200,8 +200,8 @@ namespace API.Controllers public async Task>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { // NOTE: This has to be done manually like this due to the DistinctBy requirement - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, userParams, filterDto); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var results = await _unitOfWork.SeriesRepository.GetInProgress(userId, libraryId, userParams, filterDto); var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize) .Take(userParams.PageSize).ToList(); @@ -316,14 +316,14 @@ namespace API.Controllers [HttpGet("series-by-collection")] public async Task>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, user.Id, userParams); + await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, 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); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); @@ -339,8 +339,8 @@ namespace API.Controllers public async Task>> GetAllSeriesById(SeriesByIdsDto dto) { if (dto.SeriesIds == null) return BadRequest("Must pass seriesIds"); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, user.Id)); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId)); } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 4d81eb22d..acd1b61e8 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -103,7 +103,7 @@ namespace API.Controllers } else { - _taskScheduler.ScheduleStatsTasks(); + await _taskScheduler.ScheduleStatsTasks(); } } } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index ee4c9ac66..c35e368cc 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -18,7 +18,7 @@ namespace API.Controllers { _unitOfWork = unitOfWork; } - + [Authorize(Policy = "RequireAdminRole")] [HttpDelete("delete-user")] public async Task DeleteUser(string username) @@ -30,7 +30,7 @@ namespace API.Controllers return BadRequest("Could not delete the user."); } - + [Authorize(Policy = "RequireAdminRole")] [HttpGet] public async Task>> GetUsers() @@ -42,8 +42,8 @@ namespace API.Controllers public async Task> HasReadingProgress(int libraryId) { var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, user.Id)); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId)); } [HttpGet("has-library-access")] @@ -77,8 +77,8 @@ namespace API.Controllers { return Ok(preferencesDto); } - + return BadRequest("There was an issue saving preferences."); } } -} \ No newline at end of file +} diff --git a/API/DTOs/Reader/BookInfoDto.cs b/API/DTOs/Reader/BookInfoDto.cs new file mode 100644 index 000000000..0404ab5e3 --- /dev/null +++ b/API/DTOs/Reader/BookInfoDto.cs @@ -0,0 +1,13 @@ +using API.Entities.Enums; + +namespace API.DTOs.Reader +{ + public class BookInfoDto + { + public string BookTitle { get; set; } + public int SeriesId { get; set; } + public int VolumeId { get; set; } + public MangaFormat SeriesFormat { get; set; } + public int LibraryId { get; set; } + } +} diff --git a/API/DTOs/Reader/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs index 850149016..3ad1c96e2 100644 --- a/API/DTOs/Reader/ChapterInfoDto.cs +++ b/API/DTOs/Reader/ChapterInfoDto.cs @@ -1,16 +1,21 @@ -namespace API.DTOs.Reader +using API.Entities.Enums; + +namespace API.DTOs.Reader { public class ChapterInfoDto { - + public string ChapterNumber { get; set; } public string VolumeNumber { get; set; } public int VolumeId { get; set; } public string SeriesName { get; set; } + public MangaFormat SeriesFormat { get; set; } + public int SeriesId { get; set; } + public int LibraryId { get; set; } public string ChapterTitle { get; set; } = ""; public int Pages { get; set; } public string FileName { get; set; } public bool IsSpecial { get; set; } - + } -} \ No newline at end of file +} diff --git a/API/DTOs/ReadingLists/CreateReadingListDto.cs b/API/DTOs/ReadingLists/CreateReadingListDto.cs new file mode 100644 index 000000000..c32b62bea --- /dev/null +++ b/API/DTOs/ReadingLists/CreateReadingListDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.ReadingLists +{ + public class CreateReadingListDto + { + public string Title { get; init; } + } +} diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs new file mode 100644 index 000000000..e3837a2e3 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.ReadingLists +{ + public class ReadingListDto + { + public int Id { get; init; } + public string Title { get; set; } + public string Summary { get; set; } + /// + /// Reading lists that are promoted are only done by admins + /// + public bool Promoted { get; set; } + } +} diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs new file mode 100644 index 000000000..b58fdcf80 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -0,0 +1,25 @@ +using API.Entities.Enums; + +namespace API.DTOs.ReadingLists +{ + public class ReadingListItemDto + { + public int Id { get; init; } + public int Order { get; init; } + public int ChapterId { get; init; } + public int SeriesId { get; init; } + public string SeriesName { get; set; } + public MangaFormat SeriesFormat { get; set; } + public int PagesRead { get; set; } + public int PagesTotal { get; set; } + public string ChapterNumber { get; set; } + public string VolumeNumber { get; set; } + public int VolumeId { get; set; } + public int LibraryId { get; set; } + public string Title { get; set; } + /// + /// Used internally only + /// + public int ReadingListId { get; set; } + } +} diff --git a/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs new file mode 100644 index 000000000..887850755 --- /dev/null +++ b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.ReadingLists +{ + public class UpdateReadingListByChapterDto + { + public int ChapterId { get; init; } + public int SeriesId { get; init; } + public int ReadingListId { get; init; } + } +} diff --git a/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs new file mode 100644 index 000000000..1040a9218 --- /dev/null +++ b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.ReadingLists +{ + public class UpdateReadingListBySeriesDto + { + public int SeriesId { get; init; } + public int ReadingListId { get; init; } + } +} diff --git a/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs new file mode 100644 index 000000000..0d903d48e --- /dev/null +++ b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.ReadingLists +{ + public class UpdateReadingListByVolumeDto + { + public int VolumeId { get; init; } + public int SeriesId { get; init; } + public int ReadingListId { get; init; } + } +} diff --git a/API/DTOs/ReadingLists/UpdateReadingListDto.cs b/API/DTOs/ReadingLists/UpdateReadingListDto.cs new file mode 100644 index 000000000..a9f6f0d59 --- /dev/null +++ b/API/DTOs/ReadingLists/UpdateReadingListDto.cs @@ -0,0 +1,10 @@ +namespace API.DTOs.ReadingLists +{ + public class UpdateReadingListDto + { + public int ReadingListId { get; set; } + public string Title { get; set; } + public string Summary { get; set; } + public bool Promoted { get; set; } + } +} diff --git a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs new file mode 100644 index 000000000..023849024 --- /dev/null +++ b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs @@ -0,0 +1,10 @@ +namespace API.DTOs.ReadingLists +{ + public class UpdateReadingListPosition + { + public int ReadingListId { get; set; } + public int ReadingListItemId { get; set; } + public int FromPosition { get; set; } + public int ToPosition { get; set; } + } +} diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index 3fc165a6d..7fffb79a0 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -14,6 +14,7 @@ namespace API.DTOs public DateTime LastModified { get; set; } public DateTime Created { get; set; } public bool IsSpecial { get; set; } + public int SeriesId { get; set; } public ICollection Chapters { get; set; } } -} \ No newline at end of file +} diff --git a/API/Data/BookmarkRepository.cs b/API/Data/BookmarkRepository.cs deleted file mode 100644 index af212bc72..000000000 --- a/API/Data/BookmarkRepository.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace API.Data -{ - public class BookmarkRepository - { - - } -} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 62765f607..8e4dc263e 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -35,6 +35,9 @@ namespace API.Data public DbSet SeriesMetadata { get; set; } public DbSet CollectionTag { get; set; } public DbSet AppUserBookmark { get; set; } + public DbSet ReadingList { get; set; } + public DbSet ReadingListItem { get; set; } + protected override void OnModelCreating(ModelBuilder builder) { diff --git a/API/Data/Migrations/20210901150310_ReadingLists.Designer.cs b/API/Data/Migrations/20210901150310_ReadingLists.Designer.cs new file mode 100644 index 000000000..fef65fdcf --- /dev/null +++ b/API/Data/Migrations/20210901150310_ReadingLists.Designer.cs @@ -0,0 +1,1018 @@ +// +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("20210901150310_ReadingLists")] + partial class ReadingLists + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.8"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("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("ReaderMode") + .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("RowVersion") + .IsConcurrencyToken() + .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("CoverImageLocked") + .HasColumnType("INTEGER"); + + 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("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.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.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId", "VolumeId", "ChapterId", "LibraryId") + .IsUnique(); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + 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", "Format") + .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.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.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.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.ReadingList", null) + .WithMany("Items") + .HasForeignKey("ReadingListId"); + }); + + 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("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210901150310_ReadingLists.cs b/API/Data/Migrations/20210901150310_ReadingLists.cs new file mode 100644 index 000000000..709d3e17a --- /dev/null +++ b/API/Data/Migrations/20210901150310_ReadingLists.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ReadingLists : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ReadingList", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: true), + Summary = table.Column(type: "TEXT", nullable: true), + Promoted = table.Column(type: "INTEGER", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReadingList", x => x.Id); + table.ForeignKey( + name: "FK_ReadingList_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ReadingListItem", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + LibraryId = table.Column(type: "INTEGER", nullable: false), + SeriesId = table.Column(type: "INTEGER", nullable: false), + VolumeId = table.Column(type: "INTEGER", nullable: false), + ChapterId = table.Column(type: "INTEGER", nullable: false), + Order = table.Column(type: "INTEGER", nullable: false), + ReadingListId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ReadingListItem", x => x.Id); + table.ForeignKey( + name: "FK_ReadingListItem_ReadingList_ReadingListId", + column: x => x.ReadingListId, + principalTable: "ReadingList", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_ReadingList_AppUserId", + table: "ReadingList", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListItem_ReadingListId", + table: "ReadingListItem", + column: "ReadingListId"); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListItem_SeriesId_VolumeId_ChapterId_LibraryId", + table: "ReadingListItem", + columns: new[] { "SeriesId", "VolumeId", "ChapterId", "LibraryId" }, + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReadingListItem"); + + migrationBuilder.DropTable( + name: "ReadingList"); + } + } +} diff --git a/API/Data/Migrations/20210901200442_ReadingListsAdditions.Designer.cs b/API/Data/Migrations/20210901200442_ReadingListsAdditions.Designer.cs new file mode 100644 index 000000000..8ee5bdec8 --- /dev/null +++ b/API/Data/Migrations/20210901200442_ReadingListsAdditions.Designer.cs @@ -0,0 +1,1022 @@ +// +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("20210901200442_ReadingListsAdditions")] + partial class ReadingListsAdditions + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.8"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("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("ReaderMode") + .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("RowVersion") + .IsConcurrencyToken() + .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("CoverImageLocked") + .HasColumnType("INTEGER"); + + 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("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.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.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId", "VolumeId", "ChapterId", "LibraryId") + .IsUnique(); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + 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", "Format") + .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.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.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.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ReadingList"); + }); + + 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("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210901200442_ReadingListsAdditions.cs b/API/Data/Migrations/20210901200442_ReadingListsAdditions.cs new file mode 100644 index 000000000..b44c2ac4d --- /dev/null +++ b/API/Data/Migrations/20210901200442_ReadingListsAdditions.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ReadingListsAdditions : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ReadingListItem_ReadingList_ReadingListId", + table: "ReadingListItem"); + + migrationBuilder.AlterColumn( + name: "ReadingListId", + table: "ReadingListItem", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_ReadingListItem_ReadingList_ReadingListId", + table: "ReadingListItem", + column: "ReadingListId", + principalTable: "ReadingList", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ReadingListItem_ReadingList_ReadingListId", + table: "ReadingListItem"); + + migrationBuilder.AlterColumn( + name: "ReadingListId", + table: "ReadingListItem", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AddForeignKey( + name: "FK_ReadingListItem_ReadingList_ReadingListId", + table: "ReadingListItem", + column: "ReadingListId", + principalTable: "ReadingList", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/API/Data/Migrations/20210902110705_ReadingListsExtraRealationships.Designer.cs b/API/Data/Migrations/20210902110705_ReadingListsExtraRealationships.Designer.cs new file mode 100644 index 000000000..566d2c5be --- /dev/null +++ b/API/Data/Migrations/20210902110705_ReadingListsExtraRealationships.Designer.cs @@ -0,0 +1,1050 @@ +// +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("20210902110705_ReadingListsExtraRealationships")] + partial class ReadingListsExtraRealationships + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.8"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("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("ReaderMode") + .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("RowVersion") + .IsConcurrencyToken() + .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("CoverImageLocked") + .HasColumnType("INTEGER"); + + 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("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.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.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("SeriesId", "VolumeId", "ChapterId", "LibraryId") + .IsUnique(); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + 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", "Format") + .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.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.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.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.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("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210902110705_ReadingListsExtraRealationships.cs b/API/Data/Migrations/20210902110705_ReadingListsExtraRealationships.cs new file mode 100644 index 000000000..9ddb1b5fc --- /dev/null +++ b/API/Data/Migrations/20210902110705_ReadingListsExtraRealationships.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ReadingListsExtraRealationships : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_ReadingListItem_ChapterId", + table: "ReadingListItem", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListItem_VolumeId", + table: "ReadingListItem", + column: "VolumeId"); + + migrationBuilder.AddForeignKey( + name: "FK_ReadingListItem_Chapter_ChapterId", + table: "ReadingListItem", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_ReadingListItem_Series_SeriesId", + table: "ReadingListItem", + column: "SeriesId", + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_ReadingListItem_Volume_VolumeId", + table: "ReadingListItem", + column: "VolumeId", + principalTable: "Volume", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ReadingListItem_Chapter_ChapterId", + table: "ReadingListItem"); + + migrationBuilder.DropForeignKey( + name: "FK_ReadingListItem_Series_SeriesId", + table: "ReadingListItem"); + + migrationBuilder.DropForeignKey( + name: "FK_ReadingListItem_Volume_VolumeId", + table: "ReadingListItem"); + + migrationBuilder.DropIndex( + name: "IX_ReadingListItem_ChapterId", + table: "ReadingListItem"); + + migrationBuilder.DropIndex( + name: "IX_ReadingListItem_VolumeId", + table: "ReadingListItem"); + } + } +} diff --git a/API/Data/Migrations/20210906140845_ReadingListsChanges.Designer.cs b/API/Data/Migrations/20210906140845_ReadingListsChanges.Designer.cs new file mode 100644 index 000000000..836a496e0 --- /dev/null +++ b/API/Data/Migrations/20210906140845_ReadingListsChanges.Designer.cs @@ -0,0 +1,1046 @@ +// +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("20210906140845_ReadingListsChanges")] + partial class ReadingListsChanges + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.8"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("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("ReaderMode") + .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("RowVersion") + .IsConcurrencyToken() + .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("CoverImageLocked") + .HasColumnType("INTEGER"); + + 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("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.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.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + 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", "Format") + .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.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.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.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.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("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210906140845_ReadingListsChanges.cs b/API/Data/Migrations/20210906140845_ReadingListsChanges.cs new file mode 100644 index 000000000..e4ea07e2e --- /dev/null +++ b/API/Data/Migrations/20210906140845_ReadingListsChanges.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ReadingListsChanges : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ReadingListItem_SeriesId_VolumeId_ChapterId_LibraryId", + table: "ReadingListItem"); + + migrationBuilder.DropColumn( + name: "LibraryId", + table: "ReadingListItem"); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListItem_SeriesId", + table: "ReadingListItem", + column: "SeriesId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ReadingListItem_SeriesId", + table: "ReadingListItem"); + + migrationBuilder.AddColumn( + name: "LibraryId", + table: "ReadingListItem", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListItem_SeriesId_VolumeId_ChapterId_LibraryId", + table: "ReadingListItem", + columns: new[] { "SeriesId", "VolumeId", "ChapterId", "LibraryId" }, + unique: true); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 9f763c03b..0d2c3681e 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -440,6 +440,71 @@ namespace API.Data.Migrations b.ToTable("MangaFile"); }); + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + modelBuilder.Entity("API.Entities.Series", b => { b.Property("Id") @@ -780,6 +845,52 @@ namespace API.Data.Migrations b.Navigation("Chapter"); }); + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + modelBuilder.Entity("API.Entities.Series", b => { b.HasOne("API.Entities.Library", "Library") @@ -892,6 +1003,8 @@ namespace API.Data.Migrations b.Navigation("Ratings"); + b.Navigation("ReadingLists"); + b.Navigation("UserPreferences"); b.Navigation("UserRoles"); @@ -909,6 +1022,11 @@ namespace API.Data.Migrations b.Navigation("Series"); }); + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + modelBuilder.Entity("API.Entities.Series", b => { b.Navigation("Metadata"); diff --git a/API/Data/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs similarity index 96% rename from API/Data/AppUserProgressRepository.cs rename to API/Data/Repositories/AppUserProgressRepository.cs index 38912b589..6aa19d84c 100644 --- a/API/Data/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -2,9 +2,10 @@ using System.Threading.Tasks; using API.Entities.Enums; using API.Interfaces; +using API.Interfaces.Repositories; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data.Repositories { public class AppUserProgressRepository : IAppUserProgressRepository { @@ -25,7 +26,7 @@ namespace API.Data var rowsToRemove = await _context.AppUserProgresses .Where(progress => !chapterIds.Contains(progress.ChapterId)) .ToListAsync(); - + _context.RemoveRange(rowsToRemove); return await _context.SaveChangesAsync() > 0 ? rowsToRemove.Count : 0; } @@ -45,7 +46,7 @@ namespace API.Data .ToListAsync(); if (seriesIds.Count == 0) return false; - + return await _context.Series .Include(s => s.Library) .Where(s => seriesIds.Contains(s.Id) && s.Library.Type == libraryType) @@ -53,4 +54,4 @@ namespace API.Data .AnyAsync(); } } -} \ No newline at end of file +} diff --git a/API/Data/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs similarity index 53% rename from API/Data/ChapterRepository.cs rename to API/Data/Repositories/ChapterRepository.cs index e3510adc4..56839a97c 100644 --- a/API/Data/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -1,8 +1,11 @@ -using API.Entities; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; using API.Interfaces.Repositories; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data.Repositories { public class ChapterRepository : IChapterRepository { @@ -18,6 +21,14 @@ namespace API.Data _context.Entry(chapter).State = EntityState.Modified; } + public async Task> GetChaptersByIdsAsync(IList chapterIds) + { + return await _context.Chapter + .Where(c => chapterIds.Contains(c.Id)) + .Include(c => c.Volume) + .ToListAsync(); + } + // TODO: Move over Chapter based queries here } } diff --git a/API/Data/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs similarity index 98% rename from API/Data/CollectionTagRepository.cs rename to API/Data/Repositories/CollectionTagRepository.cs index b694b0bb8..514540308 100644 --- a/API/Data/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -4,11 +4,12 @@ using System.Threading.Tasks; using API.DTOs; using API.Entities; using API.Interfaces; +using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data.Repositories { public class CollectionTagRepository : ICollectionTagRepository { diff --git a/API/Data/FileRepository.cs b/API/Data/Repositories/FileRepository.cs similarity index 92% rename from API/Data/FileRepository.cs rename to API/Data/Repositories/FileRepository.cs index c3234abba..2a0da6f20 100644 --- a/API/Data/FileRepository.cs +++ b/API/Data/Repositories/FileRepository.cs @@ -3,9 +3,10 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Interfaces; +using API.Interfaces.Repositories; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data.Repositories { public class FileRepository : IFileRepository { diff --git a/API/Data/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs similarity index 98% rename from API/Data/LibraryRepository.cs rename to API/Data/Repositories/LibraryRepository.cs index 23ad6ea76..5594eb71e 100644 --- a/API/Data/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -5,11 +5,12 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; using API.Interfaces; +using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data.Repositories { public class LibraryRepository : ILibraryRepository { diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs new file mode 100644 index 000000000..4f44bc943 --- /dev/null +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -0,0 +1,178 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.ReadingLists; +using API.Entities; +using API.Helpers; +using API.Interfaces.Repositories; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories +{ + public class ReadingListRepository : IReadingListRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public ReadingListRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(ReadingList list) + { + _context.Entry(list).State = EntityState.Modified; + } + + public void Remove(ReadingListItem item) + { + _context.ReadingListItem.Remove(item); + } + + public void BulkRemove(IEnumerable items) + { + _context.ReadingListItem.RemoveRange(items); + } + + + public async Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams) + { + var query = _context.ReadingList + .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .OrderBy(l => l.LastModified) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking(); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + public async Task GetReadingListByIdAsync(int readingListId) + { + return await _context.ReadingList + .Where(r => r.Id == readingListId) + .Include(r => r.Items) + .SingleOrDefaultAsync(); + } + + public async Task> GetReadingListItemDtosByIdAsync(int readingListId, int userId) + { + var userLibraries = _context.Library + .Include(l => l.AppUsers) + .Where(library => library.AppUsers.Any(user => user.Id == userId)) + .AsNoTracking() + .Select(library => library.Id) + .ToList(); + + var items = await _context.ReadingListItem + .Where(s => s.ReadingListId == readingListId) + .Join(_context.Chapter, s => s.ChapterId, chapter => chapter.Id, (data, chapter) => new + { + TotalPages = chapter.Pages, + ChapterNumber = chapter.Range, + readingListItem = data + }) + .Join(_context.Volume, s => s.readingListItem.VolumeId, volume => volume.Id, (data, volume) => new + { + data.readingListItem, + data.TotalPages, + data.ChapterNumber, + VolumeId = volume.Id, + VolumeNumber = volume.Name, + }) + .Join(_context.Series, s => s.readingListItem.SeriesId, series => series.Id, + (data, s) => new + { + SeriesName = s.Name, + SeriesFormat = s.Format, + s.LibraryId, + data.readingListItem, + data.TotalPages, + data.ChapterNumber, + data.VolumeNumber, + data.VolumeId + }) + .Select(data => new ReadingListItemDto() + { + Id = data.readingListItem.Id, + ChapterId = data.readingListItem.ChapterId, + Order = data.readingListItem.Order, + SeriesId = data.readingListItem.SeriesId, + SeriesName = data.SeriesName, + SeriesFormat = data.SeriesFormat, + PagesTotal = data.TotalPages, + ChapterNumber = data.ChapterNumber, + VolumeNumber = data.VolumeNumber, + LibraryId = data.LibraryId, + VolumeId = data.VolumeId, + ReadingListId = data.readingListItem.ReadingListId + }) + .Where(o => userLibraries.Contains(o.LibraryId)) + .OrderBy(rli => rli.Order) + .AsNoTracking() + .ToListAsync(); + + // Attach progress information + var fetchedChapterIds = items.Select(i => i.ChapterId); + var progresses = await _context.AppUserProgresses + .Where(p => fetchedChapterIds.Contains(p.ChapterId)) + .AsNoTracking() + .ToListAsync(); + + foreach (var progress in progresses) + { + var progressItem = items.SingleOrDefault(i => i.ChapterId == progress.ChapterId && i.ReadingListId == readingListId); + if (progressItem == null) continue; + + progressItem.PagesRead = progress.PagesRead; + } + + return items; + } + + public async Task GetReadingListDtoByIdAsync(int readingListId, int userId) + { + return await _context.ReadingList + .Where(r => r.Id == readingListId && (r.AppUserId == userId || r.Promoted)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + + public async Task> AddReadingProgressModifiers(int userId, IList items) + { + var chapterIds = items.Select(i => i.ChapterId).Distinct().ToList(); + var userProgress = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId && chapterIds.Contains(p.ChapterId)) + .AsNoTracking() + .ToListAsync(); + + foreach (var item in items) + { + var progress = userProgress.Where(p => p.ChapterId == item.ChapterId); + item.PagesRead = progress.Sum(p => p.PagesRead); + } + + return items; + } + + public async Task GetReadingListDtoByTitleAsync(string title) + { + return await _context.ReadingList + .Where(r => r.Title.Equals(title)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + + public async Task> GetReadingListItemsByIdAsync(int readingListId) + { + return await _context.ReadingListItem + .Where(r => r.ReadingListId == readingListId) + .OrderBy(r => r.Order) + .ToListAsync(); + } + + + } +} diff --git a/API/Data/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs similarity index 97% rename from API/Data/SeriesRepository.cs rename to API/Data/Repositories/SeriesRepository.cs index b9e2f16cd..2ef318bb9 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -8,11 +8,12 @@ using API.Entities; using API.Extensions; using API.Helpers; using API.Interfaces; +using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data.Repositories { public class SeriesRepository : ISeriesRepository { @@ -221,21 +222,17 @@ namespace API.Data public async Task GetChapterIdsForSeriesAsync(int[] seriesIds) { - var series = await _context.Series - .Where(s => seriesIds.Contains(s.Id)) - .Include(s => s.Volumes) - .ThenInclude(v => v.Chapters) + var volumes = await _context.Volume + .Where(v => seriesIds.Contains(v.SeriesId)) + .Include(v => v.Chapters) .ToListAsync(); IList chapterIds = new List(); - foreach (var s in series) + foreach (var v in volumes) { - foreach (var v in s.Volumes) + foreach (var c in v.Chapters) { - foreach (var c in v.Chapters) - { - chapterIds.Add(c.Id); - } + chapterIds.Add(c.Id); } } diff --git a/API/Data/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs similarity index 95% rename from API/Data/SettingsRepository.cs rename to API/Data/Repositories/SettingsRepository.cs index ecacf0f87..c6fe21961 100644 --- a/API/Data/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -5,10 +5,11 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; using API.Interfaces; +using API.Interfaces.Repositories; using AutoMapper; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data.Repositories { public class SettingsRepository : ISettingsRepository { @@ -45,4 +46,4 @@ namespace API.Data return await _context.ServerSetting.ToListAsync(); } } -} \ No newline at end of file +} diff --git a/API/Data/UserRepository.cs b/API/Data/Repositories/UserRepository.cs similarity index 84% rename from API/Data/UserRepository.cs rename to API/Data/Repositories/UserRepository.cs index 2e0b9e7f3..d6d3e80df 100644 --- a/API/Data/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -5,12 +5,13 @@ using API.Constants; using API.DTOs; using API.Entities; using API.Interfaces; +using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data.Repositories { public class UserRepository : IUserRepository { @@ -54,10 +55,36 @@ namespace API.Data } /// - /// Gets an AppUser by id. Returns back Progress information. + /// This fetches the Id for a user. Use whenever you just need an ID. /// /// /// + public async Task GetUserIdByUsernameAsync(string username) + { + return await _context.Users + .Where(x => x.UserName == username) + .Select(u => u.Id) + .SingleOrDefaultAsync(); + } + + /// + /// Gets an AppUser by username. Returns back Reading List and their Items. + /// + /// + /// + public async Task GetUserWithReadingListsByUsernameAsync(string username) + { + return await _context.Users + .Include(u => u.ReadingLists) + .ThenInclude(l => l.Items) + .SingleOrDefaultAsync(x => x.UserName == username); + } + + /// + /// Gets an AppUser by id. Returns back Progress information. + /// + /// + /// public async Task GetUserByIdAsync(int id) { return await _context.Users diff --git a/API/Data/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs similarity index 98% rename from API/Data/VolumeRepository.cs rename to API/Data/Repositories/VolumeRepository.cs index c10a3a04e..46260c411 100644 --- a/API/Data/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -4,11 +4,12 @@ using System.Threading.Tasks; using API.DTOs; using API.Entities; using API.Interfaces; +using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data.Repositories { public class VolumeRepository : IVolumeRepository { diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 25ac5654c..5d931c790 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using API.Data.Repositories; using API.Entities; using API.Interfaces; using API.Interfaces.Repositories; @@ -32,6 +33,7 @@ namespace API.Data public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); public IFileRepository FileRepository => new FileRepository(_context); public IChapterRepository ChapterRepository => new ChapterRepository(_context); + public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. @@ -39,7 +41,6 @@ namespace API.Data /// public bool Commit() { - return _context.SaveChanges() > 0; } /// diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index c4a24e405..b959fac1a 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -18,6 +18,10 @@ namespace API.Entities public AppUserPreferences UserPreferences { get; set; } public ICollection Bookmarks { get; set; } /// + /// Reading lists associated with this user + /// + public ICollection ReadingLists { get; set; } + /// /// An API Key to interact with external services, like OPDS /// public string ApiKey { get; set; } diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs new file mode 100644 index 000000000..ef0b4bd9c --- /dev/null +++ b/API/Entities/ReadingList.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using API.Entities.Interfaces; + +namespace API.Entities +{ + /// + /// This is a collection of which represent individual chapters and an order. + /// + public class ReadingList : IEntityDate + { + public int Id { get; init; } + public string Title { get; set; } + public string Summary { get; set; } + /// + /// Reading lists that are promoted are only done by admins + /// + public bool Promoted { get; set; } + + public ICollection Items { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + + // Relationships + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } + + } +} diff --git a/API/Entities/ReadingListItem.cs b/API/Entities/ReadingListItem.cs new file mode 100644 index 000000000..4a18d1c27 --- /dev/null +++ b/API/Entities/ReadingListItem.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; + +namespace API.Entities +{ + //[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), IsUnique = true)] + public class ReadingListItem + { + public int Id { get; init; } + public int SeriesId { get; set; } + public int VolumeId { get; set; } + public int ChapterId { get; set; } + /// + /// Order of the chapter within a Reading List + /// + public int Order { get; set; } + + // Relationship + public ReadingList ReadingList { get; set; } + public int ReadingListId { get; set; } + + // Idea, keep these for easy join statements + public Series Series { get; set; } + public Volume Volume { get; set; } + public Chapter Chapter { get; set; } + + } +} diff --git a/API/Extensions/FileInfoExtensions.cs b/API/Extensions/FileInfoExtensions.cs index a52141611..f7e1291e7 100644 --- a/API/Extensions/FileInfoExtensions.cs +++ b/API/Extensions/FileInfoExtensions.cs @@ -14,7 +14,6 @@ namespace API.Extensions public static bool HasFileBeenModifiedSince(this FileInfo fileInfo, DateTime comparison) { return DateTime.Compare(fileInfo.LastWriteTime, comparison) > 0; - //return fileInfo?.LastWriteTime > comparison; } } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 084a7d28c..138a9d593 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using API.DTOs; +using API.DTOs.ReadingLists; using API.Entities; using API.Helpers.Converters; using AutoMapper; @@ -31,6 +32,9 @@ namespace API.Helpers CreateMap(); + CreateMap(); + CreateMap(); + CreateMap() .ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.Id)) diff --git a/API/Interfaces/IUnitOfWork.cs b/API/Interfaces/IUnitOfWork.cs index 3f7b12146..06b47bbe8 100644 --- a/API/Interfaces/IUnitOfWork.cs +++ b/API/Interfaces/IUnitOfWork.cs @@ -14,6 +14,7 @@ namespace API.Interfaces ICollectionTagRepository CollectionTagRepository { get; } IFileRepository FileRepository { get; } IChapterRepository ChapterRepository { get; } + IReadingListRepository ReadingListRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); diff --git a/API/Interfaces/IAppUserProgressRepository.cs b/API/Interfaces/Repositories/IAppUserProgressRepository.cs similarity index 85% rename from API/Interfaces/IAppUserProgressRepository.cs rename to API/Interfaces/Repositories/IAppUserProgressRepository.cs index 96ada0c50..62449e895 100644 --- a/API/Interfaces/IAppUserProgressRepository.cs +++ b/API/Interfaces/Repositories/IAppUserProgressRepository.cs @@ -1,11 +1,11 @@ using System.Threading.Tasks; using API.Entities.Enums; -namespace API.Interfaces +namespace API.Interfaces.Repositories { public interface IAppUserProgressRepository { Task CleanupAbandonedChapters(); Task UserHasProgress(LibraryType libraryType, int userId); } -} \ No newline at end of file +} diff --git a/API/Interfaces/Repositories/IChapterRepository.cs b/API/Interfaces/Repositories/IChapterRepository.cs index 9f3f39a13..508f14e24 100644 --- a/API/Interfaces/Repositories/IChapterRepository.cs +++ b/API/Interfaces/Repositories/IChapterRepository.cs @@ -1,9 +1,13 @@ -using API.Entities; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; namespace API.Interfaces.Repositories { public interface IChapterRepository { void Update(Chapter chapter); + Task> GetChaptersByIdsAsync(IList chapterIds); } } diff --git a/API/Interfaces/ICollectionTagRepository.cs b/API/Interfaces/Repositories/ICollectionTagRepository.cs similarity index 95% rename from API/Interfaces/ICollectionTagRepository.cs rename to API/Interfaces/Repositories/ICollectionTagRepository.cs index 62f813c9d..39f814e4c 100644 --- a/API/Interfaces/ICollectionTagRepository.cs +++ b/API/Interfaces/Repositories/ICollectionTagRepository.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using API.DTOs; using API.Entities; -namespace API.Interfaces +namespace API.Interfaces.Repositories { public interface ICollectionTagRepository { diff --git a/API/Interfaces/IFileRepository.cs b/API/Interfaces/Repositories/IFileRepository.cs similarity index 81% rename from API/Interfaces/IFileRepository.cs rename to API/Interfaces/Repositories/IFileRepository.cs index cde587855..a852032d7 100644 --- a/API/Interfaces/IFileRepository.cs +++ b/API/Interfaces/Repositories/IFileRepository.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Threading.Tasks; -namespace API.Interfaces +namespace API.Interfaces.Repositories { public interface IFileRepository { Task> GetFileExtensions(); } -} \ No newline at end of file +} diff --git a/API/Interfaces/ILibraryRepository.cs b/API/Interfaces/Repositories/ILibraryRepository.cs similarity index 96% rename from API/Interfaces/ILibraryRepository.cs rename to API/Interfaces/Repositories/ILibraryRepository.cs index 4977d38d5..4d9b03fe4 100644 --- a/API/Interfaces/ILibraryRepository.cs +++ b/API/Interfaces/Repositories/ILibraryRepository.cs @@ -4,7 +4,7 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; -namespace API.Interfaces +namespace API.Interfaces.Repositories { public interface ILibraryRepository { diff --git a/API/Interfaces/Repositories/IReadingListRepository.cs b/API/Interfaces/Repositories/IReadingListRepository.cs new file mode 100644 index 000000000..8b5ab085d --- /dev/null +++ b/API/Interfaces/Repositories/IReadingListRepository.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs.ReadingLists; +using API.Entities; +using API.Helpers; + +namespace API.Interfaces.Repositories +{ + public interface IReadingListRepository + { + Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams); + Task GetReadingListByIdAsync(int readingListId); + Task> GetReadingListItemDtosByIdAsync(int readingListId, int userId); + Task GetReadingListDtoByIdAsync(int readingListId, int userId); + Task> AddReadingProgressModifiers(int userId, IList items); + Task GetReadingListDtoByTitleAsync(string title); + Task> GetReadingListItemsByIdAsync(int readingListId); + void Remove(ReadingListItem item); + void BulkRemove(IEnumerable items); + void Update(ReadingList list); + } +} diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/Repositories/ISeriesRepository.cs similarity index 98% rename from API/Interfaces/ISeriesRepository.cs rename to API/Interfaces/Repositories/ISeriesRepository.cs index a5518fc60..7388dee48 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/Repositories/ISeriesRepository.cs @@ -5,7 +5,7 @@ using API.DTOs.Filtering; using API.Entities; using API.Helpers; -namespace API.Interfaces +namespace API.Interfaces.Repositories { public interface ISeriesRepository { diff --git a/API/Interfaces/ISettingsRepository.cs b/API/Interfaces/Repositories/ISettingsRepository.cs similarity index 90% rename from API/Interfaces/ISettingsRepository.cs rename to API/Interfaces/Repositories/ISettingsRepository.cs index 5b0994d41..f1687743d 100644 --- a/API/Interfaces/ISettingsRepository.cs +++ b/API/Interfaces/Repositories/ISettingsRepository.cs @@ -4,7 +4,7 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; -namespace API.Interfaces +namespace API.Interfaces.Repositories { public interface ISettingsRepository { @@ -12,6 +12,6 @@ namespace API.Interfaces Task GetSettingsDtoAsync(); Task GetSettingAsync(ServerSettingKey key); Task> GetSettingsAsync(); - + } -} \ No newline at end of file +} diff --git a/API/Interfaces/IUserRepository.cs b/API/Interfaces/Repositories/IUserRepository.cs similarity index 86% rename from API/Interfaces/IUserRepository.cs rename to API/Interfaces/Repositories/IUserRepository.cs index c58eafdfc..01bbd4e3a 100644 --- a/API/Interfaces/IUserRepository.cs +++ b/API/Interfaces/Repositories/IUserRepository.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using API.DTOs; using API.Entities; -namespace API.Interfaces +namespace API.Interfaces.Repositories { public interface IUserRepository { @@ -11,6 +11,8 @@ namespace API.Interfaces void Update(AppUserPreferences preferences); public void Delete(AppUser user); Task GetUserByUsernameAsync(string username); + Task GetUserIdByUsernameAsync(string username); + Task GetUserWithReadingListsByUsernameAsync(string username); Task GetUserByIdAsync(int id); Task> GetMembersAsync(); Task> GetAdminUsersAsync(); diff --git a/API/Interfaces/IVolumeRepository.cs b/API/Interfaces/Repositories/IVolumeRepository.cs similarity index 94% rename from API/Interfaces/IVolumeRepository.cs rename to API/Interfaces/Repositories/IVolumeRepository.cs index 0cd703ee9..e621b643c 100644 --- a/API/Interfaces/IVolumeRepository.cs +++ b/API/Interfaces/Repositories/IVolumeRepository.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using API.DTOs; using API.Entities; -namespace API.Interfaces +namespace API.Interfaces.Repositories { public interface IVolumeRepository { diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index b7b4ee8eb..98290dccf 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -129,9 +129,14 @@ namespace API.Parser RegexTimeout), // [dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz new Regex( - @"(?.*) (\b|_|-)(vol)\.?", + @"(?.*) (\b|_|-)(vol)\.?(\s|-|_)?\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout), + // [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans] + new Regex( + @"(?.*) (\b|_|-)(vol)(ume)", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + RegexTimeout), //Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans] new Regex( @"(?.*)(\bc\d+\b)", diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index f12926c68..aebaf665c 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -161,6 +161,7 @@ namespace API.Services /// /// Not an external call. Only public so that we can call this for a Task /// + // ReSharper disable once MemberCanBePrivate.Global public async Task CheckForUpdate() { var update = await _versionUpdaterService.CheckForUpdate(); diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 9f8f3aafa..408f8d920 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -245,6 +245,28 @@ "tslib": "^2.0.0" } }, + "@angular/cdk": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-12.2.3.tgz", + "integrity": "sha512-ahY3k5X3eoQlsCX/fYiwbe1z7nfmwY15EiLpcJ8YrnUoB+ZshPm8qFIZi6gwY4tsMmUN8OfsIGcUO701bdxFpg==", + "requires": { + "parse5": "^5.0.0", + "tslib": "^2.2.0" + }, + "dependencies": { + "parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "optional": true + }, + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + } + } + }, "@angular/cli": { "version": "11.2.11", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-11.2.11.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index 42caf67ec..ba19d7e47 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -17,6 +17,7 @@ "dependencies": { "@angular-slider/ngx-slider": "^2.0.3", "@angular/animations": "~11.0.0", + "@angular/cdk": "^12.2.3", "@angular/common": "~11.0.0", "@angular/compiler": "~11.0.0", "@angular/core": "~11.0.0", diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts new file mode 100644 index 000000000..ad1a325b8 --- /dev/null +++ b/UI/Web/src/app/_models/reading-list.ts @@ -0,0 +1,23 @@ +import { MangaFormat } from "./manga-format"; + +export interface ReadingListItem { + pagesRead: number; + pagesTotal: number; + seriesName: string; + seriesFormat: MangaFormat; + seriesId: number; + chapterId: number; + order: number; + chapterNumber: string; + volumeNumber: string; + libraryId: number; + id: number; +} + +export interface ReadingList { + id: number; + title: string; + summary: string; + promoted: boolean; + items: Array; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 1640644da..a66c74a59 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -3,6 +3,7 @@ import { Chapter } from '../_models/chapter'; import { CollectionTag } from '../_models/collection-tag'; import { Library } from '../_models/library'; import { MangaFormat } from '../_models/manga-format'; +import { ReadingList } from '../_models/reading-list'; import { Series } from '../_models/series'; import { Volume } from '../_models/volume'; import { AccountService } from './account.service'; @@ -17,7 +18,8 @@ export enum Action { RefreshMetadata = 6, Download = 7, Bookmarks = 8, - IncognitoRead = 9 + IncognitoRead = 9, + AddToReadingList = 10 } export interface ActionItem { @@ -42,6 +44,8 @@ export class ActionFactoryService { collectionTagActions: Array> = []; + readingListActions: Array> = []; + isAdmin = false; hasDownloadRole = false; @@ -113,6 +117,13 @@ export class ActionFactoryService { callback: this.dummyCallback, requiresAdmin: false }); + + // this.readingListActions.push({ + // action: Action.Promote, // Should I just use CollectionTag modal-like instead? + // title: 'Delete', + // callback: this.dummyCallback, + // requiresAdmin: true + // }); } if (this.hasDownloadRole || this.isAdmin) { @@ -158,6 +169,11 @@ export class ActionFactoryService { return this.collectionTagActions; } + getReadingListActions(callback: (action: Action, readingList: ReadingList) => void) { + this.readingListActions.forEach(action => action.callback = callback); + return this.readingListActions; + } + filterBookmarksForFormat(action: ActionItem, series: Series) { if (action.action === Action.Bookmarks && series?.format === MangaFormat.EPUB) return false; return true; @@ -188,7 +204,13 @@ export class ActionFactoryService { title: 'Bookmarks', callback: this.dummyCallback, requiresAdmin: false - } + }, + { + action: Action.AddToReadingList, + title: 'Add to Reading List', + callback: this.dummyCallback, + requiresAdmin: false + }, ]; this.volumeActions = [ @@ -204,6 +226,12 @@ export class ActionFactoryService { callback: this.dummyCallback, requiresAdmin: false }, + { + action: Action.AddToReadingList, + title: 'Add to Reading List', + callback: this.dummyCallback, + requiresAdmin: false + }, { action: Action.IncognitoRead, title: 'Read in Incognito', @@ -232,8 +260,23 @@ export class ActionFactoryService { requiresAdmin: false }, { - action: Action.IncognitoRead, - title: 'Read in Incognito', + action: Action.AddToReadingList, + title: 'Add to Reading List', + callback: this.dummyCallback, + requiresAdmin: false + }, + ]; + + this.readingListActions = [ + { + action: Action.Edit, + title: 'Edit', + callback: this.dummyCallback, + requiresAdmin: false + }, + { + action: Action.Delete, + title: 'Delete', callback: this.dummyCallback, requiresAdmin: false }, diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index b336e5733..b36ac1aa9 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -4,8 +4,11 @@ import { ToastrService } from 'ngx-toastr'; import { forkJoin, Subject } from 'rxjs'; import { take, takeUntil } from 'rxjs/operators'; import { BookmarksModalComponent } from '../cards/_modals/bookmarks-modal/bookmarks-modal.component'; +import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; +import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; import { Chapter } from '../_models/chapter'; import { Library } from '../_models/library'; +import { ReadingList } from '../_models/reading-list'; import { Series } from '../_models/series'; import { Volume } from '../_models/volume'; import { LibraryService } from './library.service'; @@ -16,6 +19,7 @@ export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; export type VolumeActionCallback = (volume: Volume) => void; export type ChapterActionCallback = (chapter: Chapter) => void; +export type ReadingListActionCallback = (readingList: ReadingList) => void; /** * Responsible for executing actions @@ -27,6 +31,7 @@ export class ActionService implements OnDestroy { private readonly onDestroy = new Subject(); private bookmarkModalRef: NgbModalRef | null = null; + private readingListModalRef: NgbModalRef | null = null; constructor(private libraryService: LibraryService, private seriesService: SeriesService, private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal) { } @@ -217,4 +222,85 @@ export class ActionService implements OnDestroy { }); } + addSeriesToReadingList(series: Series, callback?: SeriesActionCallback) { + if (this.readingListModalRef != null) { return; } + this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); + this.readingListModalRef.componentInstance.seriesId = series.id; + this.readingListModalRef.componentInstance.title = series.name; + this.readingListModalRef.componentInstance.type = ADD_FLOW.Series; + + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(series); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(series); + } + }); + } + + addVolumeToReadingList(volume: Volume, seriesId: number, callback?: VolumeActionCallback) { + if (this.readingListModalRef != null) { return; } + this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); + this.readingListModalRef.componentInstance.seriesId = seriesId; + this.readingListModalRef.componentInstance.volumeId = volume.id; + this.readingListModalRef.componentInstance.type = ADD_FLOW.Volume; + + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(volume); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(volume); + } + }); + } + + addChapterToReadingList(chapter: Chapter, seriesId: number, callback?: ChapterActionCallback) { + if (this.readingListModalRef != null) { return; } + this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); + this.readingListModalRef.componentInstance.seriesId = seriesId; + this.readingListModalRef.componentInstance.chapterId = chapter.id; + this.readingListModalRef.componentInstance.type = ADD_FLOW.Chapter; + + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(chapter); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(chapter); + } + }); + } + + editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) { + const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'md' }); + readingListModalRef.componentInstance.readingList = readingList; + readingListModalRef.closed.pipe(take(1)).subscribe((list) => { + if (callback && list !== undefined) { + callback(readingList); + } + }); + readingListModalRef.dismissed.pipe(take(1)).subscribe((list) => { + if (callback && list !== undefined) { + callback(readingList); + } + }); + } + } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 450c89a8f..3ad5f8724 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -1,6 +1,5 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; import { environment } from 'src/environments/environment'; import { ChapterInfo } from '../manga-reader/_models/chapter-info'; import { UtilityService } from '../shared/_services/utility.service'; @@ -73,17 +72,23 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/mark-volume-unread', {seriesId, volumeId}); } - getNextChapter(seriesId: number, volumeId: number, currentChapterId: number) { + getNextChapter(seriesId: number, volumeId: number, currentChapterId: number, readingListId: number = -1) { + if (readingListId > 0) { + return this.httpClient.get(this.baseUrl + 'readinglist/next-chapter?seriesId=' + seriesId + '¤tChapterId=' + currentChapterId + '&readingListId=' + readingListId); + } return this.httpClient.get(this.baseUrl + 'reader/next-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '¤tChapterId=' + currentChapterId); } - getPrevChapter(seriesId: number, volumeId: number, currentChapterId: number) { + getPrevChapter(seriesId: number, volumeId: number, currentChapterId: number, readingListId: number = -1) { + if (readingListId > 0) { + return this.httpClient.get(this.baseUrl + 'readinglist/prev-chapter?seriesId=' + seriesId + '¤tChapterId=' + currentChapterId + '&readingListId=' + readingListId); + } return this.httpClient.get(this.baseUrl + 'reader/prev-chapter?seriesId=' + seriesId + '&volumeId=' + volumeId + '¤tChapterId=' + currentChapterId); } getCurrentChapter(volumes: Array): Chapter { let currentlyReadingChapter: Chapter | undefined = undefined; - const chapters = volumes.filter(v => v.number !== 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters); // changed from === 0 to != 0 + const chapters = volumes.filter(v => v.number !== 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters); for (const c of chapters) { if (c.pagesRead < c.pages) { @@ -137,4 +142,37 @@ export class ReaderService { if (imageSrc === undefined || imageSrc === '') { return -1; } return parseInt(imageSrc.split('&page=')[1], 10); } + + getNextChapterUrl(url: string, nextChapterId: number, incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) { + const lastSlashIndex = url.lastIndexOf('/'); + let newRoute = url.substring(0, lastSlashIndex + 1) + nextChapterId + ''; + newRoute += this.getQueryParams(incognitoMode, readingListMode, readingListId); + return newRoute; + } + + getQueryParamsObject(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) { + let params: {[key: string]: any} = {}; + if (incognitoMode) { + params['incognitoMode'] = true; + } + if (readingListMode) { + params['readingListId'] = readingListId; + } + return params; + } + + getQueryParams(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) { + let params = ''; + if (incognitoMode) { + params += '?incognitoMode=true'; + } + if (readingListMode) { + if (params.indexOf('?') > 0) { + params += '&readingListId=' + readingListId; + } else { + params += '?readingListId=' + readingListId; + } + } + return params; + } } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts new file mode 100644 index 000000000..f7b3343d7 --- /dev/null +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -0,0 +1,102 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { map } from 'rxjs/operators'; +import { environment } from 'src/environments/environment'; +import { PaginatedResult } from '../_models/pagination'; +import { ReadingList, ReadingListItem } from '../_models/reading-list'; +import { ActionItem } from './action-factory.service'; + +@Injectable({ + providedIn: 'root' +}) +export class ReadingListService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + getReadingList(readingListId: number) { + return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); + } + + getReadingLists(includePromoted: boolean = true, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'readinglist/lists?includePromoted=' + includePromoted, {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this._cachePaginatedResults(response, new PaginatedResult()); + }) + ); + } + + getListItems(readingListId: number) { + return this.httpClient.get(this.baseUrl + 'readinglist/items?readingListId=' + readingListId); + } + + createList(title: string) { + return this.httpClient.post(this.baseUrl + 'readinglist/create', {title}); + } + + update(model: {readingListId: number, title?: string, summary?: string, promoted: boolean}) { + return this.httpClient.post(this.baseUrl + 'readinglist/update', model, { responseType: 'text' as 'json' }); + } + + updateBySeries(readingListId: number, seriesId: number) { + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-series', {readingListId, seriesId}, { responseType: 'text' as 'json' }); + } + + updateByVolume(readingListId: number, seriesId: number, volumeId: number) { + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-volume', {readingListId, seriesId, volumeId}, { responseType: 'text' as 'json' }); + } + + updateByChapter(readingListId: number, seriesId: number, chapterId: number) { + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-chapter', {readingListId, seriesId, chapterId}, { responseType: 'text' as 'json' }); + } + + delete(readingListId: number) { + return this.httpClient.delete(this.baseUrl + 'readinglist?readingListId=' + readingListId, { responseType: 'text' as 'json' }); + } + + updatePosition(readingListId: number, readingListItemId: number, fromPosition: number, toPosition: number) { + return this.httpClient.post(this.baseUrl + 'readinglist/update-position', {readingListId, readingListItemId, fromPosition, toPosition}, { responseType: 'text' as 'json' }); + } + + deleteItem(readingListId: number, readingListItemId: number) { + return this.httpClient.post(this.baseUrl + 'readinglist/delete-item', {readingListId, readingListItemId}, { responseType: 'text' as 'json' }); + } + + removeRead(readingListId: number) { + return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, { responseType: 'text' as 'json' }); + } + + actionListFilter(action: ActionItem, readingList: ReadingList, isAdmin: boolean) { + if (readingList?.promoted && !isAdmin) return false; + return true; + } + + _addPaginationIfExists(params: HttpParams, pageNum?: number, itemsPerPage?: number) { + // TODO: Move to utility service + if (pageNum !== null && pageNum !== undefined && itemsPerPage !== null && itemsPerPage !== undefined) { + params = params.append('pageNumber', pageNum + ''); + params = params.append('pageSize', itemsPerPage + ''); + } + return params; + } + + _cachePaginatedResults(response: any, paginatedVariable: PaginatedResult) { + // TODO: Move to utility service + if (response.body === null) { + paginatedVariable.result = []; + } else { + paginatedVariable.result = response.body; + } + + const pageHeader = response.headers.get('Pagination'); + if (pageHeader !== null) { + paginatedVariable.pagination = JSON.parse(pageHeader); + } + + return paginatedVariable; + } +} diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html index 78440a06b..479107e0b 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html @@ -27,7 +27,7 @@ - +
    @@ -50,5 +50,6 @@
diff --git a/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.ts b/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.ts index f1257aa8d..441a1ca2a 100644 --- a/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.ts @@ -1,6 +1,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; import { Library } from 'src/app/_models/library'; import { LibraryService } from 'src/app/_services/library.service'; import { SettingsService } from '../../settings.service'; @@ -26,7 +27,8 @@ export class LibraryEditorModalComponent implements OnInit { libraryTypes: string[] = [] - constructor(private modalService: NgbModal, private libraryService: LibraryService, public modal: NgbActiveModal, private settingService: SettingsService) { } + constructor(private modalService: NgbModal, private libraryService: LibraryService, public modal: NgbActiveModal, private settingService: SettingsService, + private toastr: ToastrService) { } ngOnInit(): void { @@ -64,6 +66,7 @@ export class LibraryEditorModalComponent implements OnInit { model.folders = model.folders.map((item: string) => item.startsWith('\\') ? item.substr(1, item.length) : item); model.type = parseInt(model.type, 10); this.libraryService.create(model).subscribe(() => { + this.toastr.success('Library created, a scan has been started'); this.close(true); }, err => { this.errorMessage = err; diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.html b/UI/Web/src/app/admin/dashboard/dashboard.component.html index 26886dd7f..9b34f71f8 100644 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.html +++ b/UI/Web/src/app/admin/dashboard/dashboard.component.html @@ -1,7 +1,7 @@

Admin Dashboard

-

Table of Contents

@@ -77,18 +77,18 @@ @@ -122,12 +122,22 @@
- + -
{{bookTitle}}
+
{{bookTitle}} (Incognito Mode)
- +
diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index 51d5fbfeb..c37993c2d 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -22,6 +22,7 @@ import { Preferences } from 'src/app/_models/preferences/preferences'; import { MemberService } from 'src/app/_services/member.service'; import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; import { ScrollService } from 'src/app/scroll.service'; +import { MangaFormat } from 'src/app/_models/manga-format'; interface PageStyle { @@ -38,7 +39,8 @@ interface HistoryPoint { } const TOP_OFFSET = -50 * 1.5; // px the sticky header takes up -const SCROLL_PART_TIMEOUT = 5000; +const CHAPTER_ID_NOT_FETCHED = -2; +const CHAPTER_ID_DOESNT_EXIST = -1; @Component({ selector: 'app-book-reader', @@ -65,15 +67,30 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { chapterId!: number; chapter!: Chapter; /** - * If we should save progress or not + * Reading List id. Defaults to -1. */ + readingListId: number = CHAPTER_ID_DOESNT_EXIST; + + /** + * If this is true, no progress will be saved. + */ incognitoMode: boolean = false; + + /** + * If this is true, chapters will be fetched in the order of a reading list, rather than natural series order. + */ + readingListMode: boolean = false; chapters: Array = []; pageNum = 0; maxPages = 1; adhocPageHistory: Stack = new Stack(); + /** + * A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls. + * @see Stack + */ + continuousChaptersStack: Stack = new Stack(); user!: User; @@ -94,6 +111,38 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef; @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef; + /** + * Next Chapter Id. This is not garunteed to be a valid ChapterId. Prefetched on page load (non-blocking). + */ + nextChapterId: number = CHAPTER_ID_NOT_FETCHED; + /** + * Previous Chapter Id. This is not garunteed to be a valid ChapterId. Prefetched on page load (non-blocking). + */ + prevChapterId: number = CHAPTER_ID_NOT_FETCHED; + /** + * Is there a next chapter. If not, this will disable UI controls. + */ + nextChapterDisabled: boolean = false; + /** + * Is there a previous chapter. If not, this will disable UI controls. + */ + prevChapterDisabled: boolean = false; + /** + * Has the next chapter been prefetched. Prefetched means the backend will cache the files. + */ + nextChapterPrefetched: boolean = false; + /** + * Has the previous chapter been prefetched. Prefetched means the backend will cache the files. + */ + prevChapterPrefetched: boolean = false; + /** + * If the prev page allows a page change to occur. + */ + prevPageDisabled = false; + /** + * If the next page allows a page change to occur. + */ + nextPageDisabled = false; /** * Internal property used to capture all the different css properties to render on all elements @@ -122,6 +171,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Last seen progress part path */ lastSeenScrollPartPath: string = ''; + + + /** * Hack: Override background color for reader and restore it onDestroy */ @@ -151,6 +203,24 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } `; + get ReadingDirection(): typeof ReadingDirection { + return ReadingDirection; + } + + get IsPrevDisabled() { + if (this.readingDirection === ReadingDirection.LeftToRight) { + return this.prevPageDisabled && this.pageNum === 0; + } + return this.nextPageDisabled && this.pageNum + 1 >= this.maxPages - 1; + } + + get IsNextDisabled() { + if (this.readingDirection === ReadingDirection.LeftToRight) { + this.nextPageDisabled && this.pageNum + 1 >= this.maxPages - 1; + } + return this.prevPageDisabled && this.pageNum === 0; + } + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private seriesService: SeriesService, private readerService: ReaderService, private location: Location, private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, @@ -274,6 +344,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.chapterId = parseInt(chapterId, 10); this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true'; + const readingListId = this.route.snapshot.queryParamMap.get('readingListId'); + if (readingListId != null) { + this.readingListMode = true; + this.readingListId = parseInt(readingListId, 10); + } + + this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => { if (!hasProgress) { this.toggleDrawer(); @@ -281,34 +358,71 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } }); - forkJoin({ - chapter: this.seriesService.getChapter(this.chapterId), - progress: this.readerService.getProgress(this.chapterId), - chapters: this.bookService.getBookChapters(this.chapterId), - info: this.bookService.getBookInfo(this.chapterId) - }).pipe(take(1)).subscribe(results => { - this.chapter = results.chapter; - this.volumeId = results.chapter.volumeId; - this.maxPages = results.chapter.pages; - this.chapters = results.chapters; - this.pageNum = results.progress.pageNum; - this.bookTitle = results.info; + this.init(); + } + init() { + this.nextChapterId = CHAPTER_ID_NOT_FETCHED; + this.prevChapterId = CHAPTER_ID_NOT_FETCHED; + this.nextChapterDisabled = false; + this.prevChapterDisabled = false; + this.nextChapterPrefetched = false; - if (this.pageNum >= this.maxPages) { - this.pageNum = this.maxPages - 1; - if (!this.incognitoMode) { - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */}); - } + this.bookService.getBookInfo(this.chapterId).subscribe(info => { + this.bookTitle = info.bookTitle; + + if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) { + // Redirect to the manga reader. + const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId); + this.router.navigate(['library', info.libraryId, 'series', info.seriesId, 'manga', this.chapterId], {queryParams: params}); + return; } - // Check if user progress has part, if so load it so we scroll to it - this.loadPage(results.progress.bookScrollId || undefined); - }, () => { - setTimeout(() => { - this.closeReader(); - }, 200); + forkJoin({ + chapter: this.seriesService.getChapter(this.chapterId), + progress: this.readerService.getProgress(this.chapterId), + chapters: this.bookService.getBookChapters(this.chapterId), + }).pipe(take(1)).subscribe(results => { + this.chapter = results.chapter; + this.volumeId = results.chapter.volumeId; + this.maxPages = results.chapter.pages; + this.chapters = results.chapters; + this.pageNum = results.progress.pageNum; + + + this.continuousChaptersStack.push(this.chapterId); + + + if (this.pageNum >= this.maxPages) { + this.pageNum = this.maxPages - 1; + if (!this.incognitoMode) { + this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */}); + } + } + + this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { + this.nextChapterId = chapterId; + if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { + this.nextChapterDisabled = true; + } + }); + this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { + this.prevChapterId = chapterId; + if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { + this.prevChapterDisabled = true; + } + }); + + // Check if user progress has part, if so load it so we scroll to it + this.loadPage(results.progress.bookScrollId || undefined); + }, () => { + setTimeout(() => { + this.closeReader(); + }, 200); + }); }); + + } @HostListener('window:keydown', ['$event']) @@ -353,7 +467,63 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - loadChapter(pageNum: number, part: string) { + loadNextChapter() { + if (this.nextPageDisabled) { return; } + this.isLoading = true; + if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) { + this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { + this.nextChapterId = chapterId; + this.loadChapter(chapterId, 'next'); + }); + } else { + this.loadChapter(this.nextChapterId, 'next'); + } + } + + loadPrevChapter() { + if (this.prevPageDisabled) { return; } + this.isLoading = true; + this.continuousChaptersStack.pop(); + const prevChapter = this.continuousChaptersStack.peek(); + if (prevChapter != this.chapterId) { + if (prevChapter !== undefined) { + this.chapterId = prevChapter; + this.init(); + return; + } + } + + if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) { + this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { + this.prevChapterId = chapterId; + this.loadChapter(chapterId, 'prev'); + }); + } else { + this.loadChapter(this.prevChapterId, 'prev'); + } + } + + loadChapter(chapterId: number, direction: 'next' | 'prev') { + if (chapterId >= 0) { + this.chapterId = chapterId; + this.continuousChaptersStack.push(chapterId); + // Load chapter Id onto route but don't reload + const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); + window.history.replaceState({}, '', newRoute); + this.init(); + } else { + // This will only happen if no actual chapter can be found + this.toastr.warning('Could not find ' + direction + ' chapter'); + this.isLoading = false; + if (direction === 'prev') { + this.prevPageDisabled = true; + } else { + this.nextPageDisabled = true; + } + } + } + + loadChapterPage(pageNum: number, part: string) { this.setPageNum(pageNum); this.loadPage('id("' + part + '")'); } @@ -572,7 +742,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.setPageNum(this.pageNum + 1); } + if (oldPageNum === 0) { + // Move to next volume/chapter automatically + this.loadPrevChapter(); + return; + } + if (oldPageNum === this.pageNum) { return; } + this.loadPage(); } @@ -588,7 +765,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else { this.setPageNum(this.pageNum - 1); } - + + if (this.pageNum >= this.maxPages - 1) { + // Move to next volume/chapter automatically + this.loadNextChapter(); + } + if (oldPageNum === this.pageNum) { return; } this.loadPage(); diff --git a/UI/Web/src/app/book-reader/book.service.ts b/UI/Web/src/app/book-reader/book.service.ts index d3d650600..bef4ce73d 100644 --- a/UI/Web/src/app/book-reader/book.service.ts +++ b/UI/Web/src/app/book-reader/book.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; import { BookChapterItem } from './_models/book-chapter-item'; +import { BookInfo } from './_models/book-info'; export interface BookPage { bookTitle: string; @@ -32,7 +33,7 @@ export class BookService { } getBookInfo(chapterId: number) { - return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-info', {responseType: 'text' as 'json'}); + return this.http.get(this.baseUrl + 'book/' + chapterId + '/book-info'); } getBookPageUrl(chapterId: number, page: number) { diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html index 4542f7938..d18987f6b 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html @@ -37,8 +37,8 @@
- -  Chapter {{formatChapterNumber(chapter)}} +   + Chapter {{formatChapterNumber(chapter)}} {{chapter.pagesRead}} / {{chapter.pages}} UNREAD diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html index 168c9cf5d..2f7caacaf 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html @@ -11,7 +11,7 @@ Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.

-