From cf7a9aa71e65afcfd25520b54ea1349fbbb4272f Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Wed, 8 Sep 2021 10:03:27 -0700 Subject: [PATCH] Reading Lists & More (#564) * Added continous reading to the book reader. Clicking on the max pages to right of progress bar will now go to last page. * Forgot a file for continous book reading * Fixed up some code regarding transitioning between chapters. Arrows now show to represent a chapter transition. * Laid the foundation for reading lists * All foundation is laid out. Actions are wired in the UI. Backend repository is setup. Redid the migration to have ReadingList track modification so we can order them for the user. * Updated add modal to have basic skeleton * Hooked up ability to fetch reading lists from backend * Made a huge performance improvement to GetChapterIdsForSeriesAsync() by reducing a JOIN and an iteration loop. Improvement went from 2 seconds -> 200 ms. * Implemented the ability to add all chapters in a series to a reading list. * Fixed issue with adding new items to reading list not being in a logical order. Lots of work on getting all the information around the reading list view. Added some foreign keys back to chapter so delete should clean up after itself. * Added ability to open directly the series * Reading List Items now have progress attached * Hooked up list deletion and added a case where if doesn't exist on load, then redirect to library. * Lots of changes. Introduced a dashboard component for the main app. This will sit on libraries route for now and will have 3 tabs to show different sections. Moved libraries reel down to bottom as people are more likely to access recently added or in progress than explore their whole library. Note: Bundles are messed up, they need to be reoptimized and routes need to be updated. * Added pagination to the reading lists api and implemented a page to show all lists * Cleaned up old code from all-collections component so now it only handles all collections and doesn't have the old code for an individual collection * Hooked in actions and navigation on reading lists * When the user re-arranges items, they are now persisted * Implemented remove read, but performance is pretty poor. Needs to be optimized. * Lots of API fixes for adding items to a series, returning items, etc. Committing before fixing incorrect fetches of items for a readingListId. * Rewrote the joins for GetReadingListItemDtosByIdAsync() to not return extra records. * Remove bug marker now that it is fixed * Refactor update-by-series to move more of the code to a re-usable function for update-by-volume/chapter APIs * Implemented the ability to add via series, volume or chapter. * Added OPDS support for reading lists. This included adding VolumeId to the ReadingListDto. * Fixed a bug with deleting items * After we create a library inform user that a scan has started * Added some extra help information for users on directory picker, since linux users were getting confused. * Setup for the reading functionality * Fixed an issue where opening the edit series modal and pressing save without doing anything would empty collection tags. Would happen often when editing cover images. * Fixed get-next-chapter for reading list. Refactored all methods to use the new GetUserIdByUsernameAsync(), which is much faster and uses less memory. * Hooked in prev chapter for continuous reading with reading list * Hooked up the read code for manga reader and book reader to have list id passed * Manga reader now functions completely with reading lists * Implemented reading list and incognito mode into book reader * Refactored some common reading code into reader service * Added support for "Series - - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz" format that can occur with FMD2. * Implemented continuous reading with a reading list between different readers. This incurs a 3x performance hit on the book info api. * style changes. Don't emit an event if position of draggable item hasn't changed * Styling and added the edit reading list flow. * Cleaned up some extra spaces when actionables isn't shown. Lots of cleanup for promoted lists. * Refactored some filter code to a common service * Added an RBS check in getting Items for a given user. * Code smells * More smells --- API.Benchmark/ParseScannedFilesBenchmarks.cs | 1 - API.Tests/Parser/MangaParserTests.cs | 3 +- API/Controllers/BookController.cs | 27 +- API/Controllers/LibraryController.cs | 6 +- API/Controllers/OPDSController.cs | 84 ++ API/Controllers/ReaderController.cs | 22 +- API/Controllers/ReadingListController.cs | 404 +++++++ API/Controllers/SeriesController.cs | 38 +- API/Controllers/SettingsController.cs | 2 +- API/Controllers/UsersController.cs | 12 +- API/DTOs/Reader/BookInfoDto.cs | 13 + API/DTOs/Reader/ChapterInfoDto.cs | 13 +- API/DTOs/ReadingLists/CreateReadingListDto.cs | 7 + API/DTOs/ReadingLists/ReadingListDto.cs | 13 + API/DTOs/ReadingLists/ReadingListItemDto.cs | 25 + .../UpdateReadingListByChapterDto.cs | 9 + .../UpdateReadingListBySeriesDto.cs | 8 + .../UpdateReadingListByVolumeDto.cs | 9 + API/DTOs/ReadingLists/UpdateReadingListDto.cs | 10 + .../ReadingLists/UpdateReadingListPosition.cs | 10 + API/DTOs/VolumeDto.cs | 3 +- API/Data/BookmarkRepository.cs | 7 - API/Data/DataContext.cs | 3 + .../20210901150310_ReadingLists.Designer.cs | 1018 ++++++++++++++++ .../Migrations/20210901150310_ReadingLists.cs | 84 ++ ...01200442_ReadingListsAdditions.Designer.cs | 1022 ++++++++++++++++ .../20210901200442_ReadingListsAdditions.cs | 55 + ...eadingListsExtraRealationships.Designer.cs | 1050 +++++++++++++++++ ...2110705_ReadingListsExtraRealationships.cs | 67 ++ ...0906140845_ReadingListsChanges.Designer.cs | 1046 ++++++++++++++++ .../20210906140845_ReadingListsChanges.cs | 43 + .../Migrations/DataContextModelSnapshot.cs | 118 ++ .../AppUserProgressRepository.cs | 9 +- .../{ => Repositories}/ChapterRepository.cs | 15 +- .../CollectionTagRepository.cs | 3 +- API/Data/{ => Repositories}/FileRepository.cs | 3 +- .../{ => Repositories}/LibraryRepository.cs | 3 +- .../Repositories/ReadingListRepository.cs | 178 +++ .../{ => Repositories}/SeriesRepository.cs | 19 +- .../{ => Repositories}/SettingsRepository.cs | 5 +- API/Data/{ => Repositories}/UserRepository.cs | 31 +- .../{ => Repositories}/VolumeRepository.cs | 3 +- API/Data/UnitOfWork.cs | 3 +- API/Entities/AppUser.cs | 4 + API/Entities/ReadingList.cs | 29 + API/Entities/ReadingListItem.cs | 27 + API/Extensions/FileInfoExtensions.cs | 1 - API/Helpers/AutoMapperProfiles.cs | 4 + API/Interfaces/IUnitOfWork.cs | 1 + .../IAppUserProgressRepository.cs | 4 +- .../Repositories/IChapterRepository.cs | 6 +- .../ICollectionTagRepository.cs | 2 +- .../{ => Repositories}/IFileRepository.cs | 4 +- .../{ => Repositories}/ILibraryRepository.cs | 2 +- .../Repositories/IReadingListRepository.cs | 22 + .../{ => Repositories}/ISeriesRepository.cs | 2 +- .../{ => Repositories}/ISettingsRepository.cs | 6 +- .../{ => Repositories}/IUserRepository.cs | 4 +- .../{ => Repositories}/IVolumeRepository.cs | 2 +- API/Parser/Parser.cs | 7 +- API/Services/TaskScheduler.cs | 1 + UI/Web/package-lock.json | 22 + UI/Web/package.json | 1 + UI/Web/src/app/_models/reading-list.ts | 23 + .../app/_services/action-factory.service.ts | 51 +- UI/Web/src/app/_services/action.service.ts | 86 ++ UI/Web/src/app/_services/reader.service.ts | 46 +- .../src/app/_services/reading-list.service.ts | 102 ++ .../directory-picker.component.html | 3 +- .../library-editor-modal.component.ts | 5 +- .../admin/dashboard/dashboard.component.html | 2 +- UI/Web/src/app/app-routing.module.ts | 8 +- UI/Web/src/app/app.module.ts | 14 +- .../src/app/book-reader/_models/book-info.ts | 9 + .../book-reader/book-reader.component.html | 24 +- .../book-reader/book-reader.component.ts | 236 +++- UI/Web/src/app/book-reader/book.service.ts | 3 +- .../card-details-modal.component.html | 4 +- .../edit-collection-tags.component.html | 2 +- .../edit-series-modal.component.ts | 2 + .../card-detail-layout.component.html | 4 +- .../cards/card-item/card-item.component.ts | 1 + .../series-card/series-card.component.ts | 3 + .../all-collections.component.html | 19 +- .../all-collections.component.ts | 69 +- .../src/app/collections/collections.module.ts | 3 + .../app/dashboard/dashboard.component.html | 21 + .../app/dashboard/dashboard.component.scss | 0 .../src/app/dashboard/dashboard.component.ts | 37 + UI/Web/src/app/library/library.component.html | 51 +- UI/Web/src/app/library/library.component.ts | 50 +- .../app/manga-reader/_models/chapter-info.ts | 5 + .../manga-reader/manga-reader.component.ts | 39 +- .../manga-reader.router.module.ts | 7 +- .../app/nav-header/nav-header.component.ts | 2 +- .../add-to-list-modal.component.html | 37 + .../add-to-list-modal.component.scss | 7 + .../add-to-list-modal.component.ts | 90 ++ .../edit-reading-list-modal.component.html | 31 + .../edit-reading-list-modal.component.scss | 0 .../edit-reading-list-modal.component.ts | 52 + .../dragable-ordered-list.component.html | 20 + .../dragable-ordered-list.component.scss | 53 + .../dragable-ordered-list.component.ts | 63 + .../reading-list-detail.component.html | 58 + .../reading-list-detail.component.scss | 0 .../reading-list-detail.component.ts | 153 +++ .../app/reading-list/reading-list.module.ts | 36 + .../reading-list.router.module.ts | 24 + .../reading-lists.component.html | 11 + .../reading-lists.component.scss | 0 .../reading-lists/reading-lists.component.ts | 88 ++ .../series-detail.component.html | 2 +- .../series-detail/series-detail.component.ts | 9 + UI/Web/src/app/shared/shared.module.ts | 2 - .../user-preferences.component.html | 28 +- UI/Web/src/assets/themes/dark.scss | 5 + 117 files changed, 7050 insertions(+), 305 deletions(-) create mode 100644 API/Controllers/ReadingListController.cs create mode 100644 API/DTOs/Reader/BookInfoDto.cs create mode 100644 API/DTOs/ReadingLists/CreateReadingListDto.cs create mode 100644 API/DTOs/ReadingLists/ReadingListDto.cs create mode 100644 API/DTOs/ReadingLists/ReadingListItemDto.cs create mode 100644 API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs create mode 100644 API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs create mode 100644 API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs create mode 100644 API/DTOs/ReadingLists/UpdateReadingListDto.cs create mode 100644 API/DTOs/ReadingLists/UpdateReadingListPosition.cs delete mode 100644 API/Data/BookmarkRepository.cs create mode 100644 API/Data/Migrations/20210901150310_ReadingLists.Designer.cs create mode 100644 API/Data/Migrations/20210901150310_ReadingLists.cs create mode 100644 API/Data/Migrations/20210901200442_ReadingListsAdditions.Designer.cs create mode 100644 API/Data/Migrations/20210901200442_ReadingListsAdditions.cs create mode 100644 API/Data/Migrations/20210902110705_ReadingListsExtraRealationships.Designer.cs create mode 100644 API/Data/Migrations/20210902110705_ReadingListsExtraRealationships.cs create mode 100644 API/Data/Migrations/20210906140845_ReadingListsChanges.Designer.cs create mode 100644 API/Data/Migrations/20210906140845_ReadingListsChanges.cs rename API/Data/{ => Repositories}/AppUserProgressRepository.cs (96%) rename API/Data/{ => Repositories}/ChapterRepository.cs (53%) rename API/Data/{ => Repositories}/CollectionTagRepository.cs (98%) rename API/Data/{ => Repositories}/FileRepository.cs (92%) rename API/Data/{ => Repositories}/LibraryRepository.cs (98%) create mode 100644 API/Data/Repositories/ReadingListRepository.cs rename API/Data/{ => Repositories}/SeriesRepository.cs (97%) rename API/Data/{ => Repositories}/SettingsRepository.cs (95%) rename API/Data/{ => Repositories}/UserRepository.cs (84%) rename API/Data/{ => Repositories}/VolumeRepository.cs (98%) create mode 100644 API/Entities/ReadingList.cs create mode 100644 API/Entities/ReadingListItem.cs rename API/Interfaces/{ => Repositories}/IAppUserProgressRepository.cs (85%) rename API/Interfaces/{ => Repositories}/ICollectionTagRepository.cs (95%) rename API/Interfaces/{ => Repositories}/IFileRepository.cs (81%) rename API/Interfaces/{ => Repositories}/ILibraryRepository.cs (96%) create mode 100644 API/Interfaces/Repositories/IReadingListRepository.cs rename API/Interfaces/{ => Repositories}/ISeriesRepository.cs (98%) rename API/Interfaces/{ => Repositories}/ISettingsRepository.cs (90%) rename API/Interfaces/{ => Repositories}/IUserRepository.cs (86%) rename API/Interfaces/{ => Repositories}/IVolumeRepository.cs (94%) create mode 100644 UI/Web/src/app/_models/reading-list.ts create mode 100644 UI/Web/src/app/_services/reading-list.service.ts create mode 100644 UI/Web/src/app/book-reader/_models/book-info.ts create mode 100644 UI/Web/src/app/dashboard/dashboard.component.html create mode 100644 UI/Web/src/app/dashboard/dashboard.component.scss create mode 100644 UI/Web/src/app/dashboard/dashboard.component.ts create mode 100644 UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.html create mode 100644 UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.scss create mode 100644 UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.ts create mode 100644 UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.html create mode 100644 UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.scss create mode 100644 UI/Web/src/app/reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component.ts create mode 100644 UI/Web/src/app/reading-list/dragable-ordered-list/dragable-ordered-list.component.html create mode 100644 UI/Web/src/app/reading-list/dragable-ordered-list/dragable-ordered-list.component.scss create mode 100644 UI/Web/src/app/reading-list/dragable-ordered-list/dragable-ordered-list.component.ts create mode 100644 UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html create mode 100644 UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.scss create mode 100644 UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts create mode 100644 UI/Web/src/app/reading-list/reading-list.module.ts create mode 100644 UI/Web/src/app/reading-list/reading-list.router.module.ts create mode 100644 UI/Web/src/app/reading-list/reading-lists/reading-lists.component.html create mode 100644 UI/Web/src/app/reading-list/reading-lists/reading-lists.component.scss create mode 100644 UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts 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.

-