From deaaccb96a8a62e087c68d184b38a7f94c574b50 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 6 Apr 2024 12:03:49 -0500 Subject: [PATCH] Collection Rework (#2830) --- API.Tests/AbstractDbTest.cs | 2 + .../Extensions/EnumerableExtensionsTests.cs | 8 +- .../Extensions/QueryableExtensionsTests.cs | 16 +- .../CollectionTagRepositoryTests.cs | 122 +- API.Tests/Services/CleanupServiceTests.cs | 118 +- .../Services/CollectionTagServiceTests.cs | 173 +- API.Tests/Services/SeriesServiceTests.cs | 54 +- API/Constants/PolicyConstants.cs | 8 +- API/Controllers/CollectionController.cs | 152 +- API/Controllers/ImageController.cs | 3 +- API/Controllers/OPDSController.cs | 39 +- API/Controllers/SearchController.cs | 2 +- API/Controllers/UploadController.cs | 2 +- API/Controllers/WantToReadController.cs | 1 + API/DTOs/Collection/AppUserCollectionDto.cs | 39 + API/DTOs/Collection/DeleteCollectionsDto.cs | 8 + API/DTOs/Collection/PromoteCollectionsDto.cs | 9 + API/DTOs/ReadingLists/ReadingListDto.cs | 3 +- API/DTOs/Search/SearchResultGroupDto.cs | 3 +- API/DTOs/SeriesMetadataDto.cs | 6 - API/DTOs/UpdateSeriesMetadataDto.cs | 7 +- API/Data/DataContext.cs | 6 + .../MigrateCollectionTagToUserCollections.cs | 80 + ...331172900_UserBasedCollections.Designer.cs | 3019 +++++++++++++++++ .../20240331172900_UserBasedCollections.cs | 92 + .../Migrations/DataContextModelSnapshot.cs | 103 + .../Repositories/CollectionTagRepository.cs | 162 +- API/Data/Repositories/SeriesRepository.cs | 163 +- API/Data/Repositories/UserRepository.cs | 24 +- API/Entities/AppUser.cs | 4 + API/Entities/AppUserCollection.cs | 60 + API/Entities/CollectionTag.cs | 1 + API/Entities/Metadata/SeriesMetadata.cs | 1 + API/Entities/Series.cs | 12 + .../Filtering/SearchQueryableExtensions.cs | 76 + .../QueryExtensions/Filtering/SeriesFilter.cs | 11 +- .../QueryExtensions/IncludesExtensions.cs | 23 + .../RestrictByAgeExtensions.cs | 18 +- API/Helpers/AutoMapperProfiles.cs | 7 +- .../Builders/AppUserCollectionBuilder.cs | 72 + API/Helpers/Builders/CollectionTagBuilder.cs | 57 - API/Services/CollectionTagService.cs | 150 +- API/Services/MetadataService.cs | 2 +- API/Services/SeriesService.cs | 72 +- API/Services/Tasks/CleanupService.cs | 2 +- .../Tasks/Scanner/ParseScannedFiles.cs | 12 +- API/Services/Tasks/Scanner/ProcessSeries.cs | 21 +- .../Tasks/Scanner/TagManagerService.cs | 34 +- API/Services/Tasks/StatsService.cs | 2 +- API/Startup.cs | 1 + UI/Web/src/app/_models/collection-tag.ts | 44 +- .../app/_models/metadata/series-metadata.ts | 2 - .../src/app/_pipes/manga-format-icon.pipe.ts | 10 +- UI/Web/src/app/_services/account.service.ts | 8 +- .../app/_services/action-factory.service.ts | 58 +- UI/Web/src/app/_services/action.service.ts | 42 +- .../app/_services/collection-tag.service.ts | 48 +- UI/Web/src/app/_services/series.service.ts | 10 +- .../card-actionables.component.ts | 28 +- .../bulk-add-to-collection.component.html | 46 +- .../bulk-add-to-collection.component.ts | 23 +- .../edit-collection-tags.component.html | 78 +- .../edit-collection-tags.component.ts | 111 +- .../edit-series-modal.component.html | 30 +- .../edit-series-modal.component.ts | 44 +- .../src/app/cards/bulk-selection.service.ts | 6 +- .../cards/card-item/card-item.component.html | 68 +- .../cards/card-item/card-item.component.ts | 27 +- .../series-info-cards.component.html | 2 +- .../all-collections.component.html | 14 +- .../all-collections.component.ts | 136 +- .../collection-detail.component.ts | 69 +- .../collection-owner.component.html | 14 + .../collection-owner.component.scss | 4 + .../collection-owner.component.ts | 35 + .../metadata-filter-row.component.ts | 2 +- .../nav-header/nav-header.component.html | 12 +- .../nav-header/nav-header.component.ts | 14 +- .../reading-list-detail.component.ts | 2 - .../reading-list-item.component.html | 6 +- .../reading-list-item.component.ts | 3 +- .../metadata-detail.component.ts | 1 + .../series-metadata-detail.component.html | 17 +- .../series-metadata-detail.component.ts | 18 +- .../promoted-icon.component.html | 8 + .../promoted-icon.component.scss | 0 .../promoted-icon/promoted-icon.component.ts | 16 + .../app/shared/_services/download.service.ts | 4 +- .../series-format.component.html | 4 +- .../series-format/series-format.component.ts | 6 +- UI/Web/src/assets/langs/en.json | 34 +- UI/Web/src/theme/themes/dark.scss | 4 +- openapi.json | 363 +- 93 files changed, 5413 insertions(+), 1120 deletions(-) create mode 100644 API/DTOs/Collection/AppUserCollectionDto.cs create mode 100644 API/DTOs/Collection/DeleteCollectionsDto.cs create mode 100644 API/DTOs/Collection/PromoteCollectionsDto.cs create mode 100644 API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs create mode 100644 API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs create mode 100644 API/Data/Migrations/20240331172900_UserBasedCollections.cs create mode 100644 API/Entities/AppUserCollection.cs create mode 100644 API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs create mode 100644 API/Helpers/Builders/AppUserCollectionBuilder.cs delete mode 100644 API/Helpers/Builders/CollectionTagBuilder.cs create mode 100644 UI/Web/src/app/collections/_components/collection-owner/collection-owner.component.html create mode 100644 UI/Web/src/app/collections/_components/collection-owner/collection-owner.component.scss create mode 100644 UI/Web/src/app/collections/_components/collection-owner/collection-owner.component.ts create mode 100644 UI/Web/src/app/shared/_components/promoted-icon/promoted-icon.component.html create mode 100644 UI/Web/src/app/shared/_components/promoted-icon/promoted-icon.component.scss create mode 100644 UI/Web/src/app/shared/_components/promoted-icon/promoted-icon.component.ts diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index 18f0669cd..a3464db9d 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -10,6 +10,7 @@ using API.Helpers; using API.Helpers.Builders; using API.Services; using AutoMapper; +using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -47,6 +48,7 @@ public abstract class AbstractDbTest var config = new MapperConfiguration(cfg => cfg.AddProfile()); var mapper = config.CreateMapper(); + _unitOfWork = new UnitOfWork(_context, mapper, null); } diff --git a/API.Tests/Extensions/EnumerableExtensionsTests.cs b/API.Tests/Extensions/EnumerableExtensionsTests.cs index e115d45f3..bdd3433ae 100644 --- a/API.Tests/Extensions/EnumerableExtensionsTests.cs +++ b/API.Tests/Extensions/EnumerableExtensionsTests.cs @@ -74,10 +74,10 @@ public class EnumerableExtensionsTests new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"}, new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"} )] - [InlineData( - new[] {"01/001.jpg", "001.jpg"}, - new[] {"001.jpg", "01/001.jpg"} - )] + [InlineData( + new[] {"01/001.jpg", "001.jpg"}, + new[] {"001.jpg", "01/001.jpg"} + )] public void TestNaturalSort(string[] input, string[] expected) { Assert.Equal(expected, input.OrderByNatural(x => x).ToArray()); diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index 230028d44..771ba940c 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -45,17 +45,17 @@ public class QueryableExtensionsTests [InlineData(false, 1)] public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) { - var items = new List() + var items = new List() { - new CollectionTagBuilder("Test") - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) + new AppUserCollectionBuilder("Test") + .WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build()) .Build(), - new CollectionTagBuilder("Test 2") - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) + new AppUserCollectionBuilder("Test 2") + .WithItem(new SeriesBuilder("S2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()).Build()) + .WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build()) .Build(), - new CollectionTagBuilder("Test 3") - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()) + new AppUserCollectionBuilder("Test 3") + .WithItem(new SeriesBuilder("S3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()).Build()) .Build(), }; diff --git a/API.Tests/Repository/CollectionTagRepositoryTests.cs b/API.Tests/Repository/CollectionTagRepositoryTests.cs index 1859ab1fc..6abf3f7e7 100644 --- a/API.Tests/Repository/CollectionTagRepositoryTests.cs +++ b/API.Tests/Repository/CollectionTagRepositoryTests.cs @@ -114,65 +114,65 @@ public class CollectionTagRepositoryTests #endregion - #region RemoveTagsWithoutSeries - - [Fact] - public async Task RemoveTagsWithoutSeries_ShouldRemoveTags() - { - var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); - var series = new SeriesBuilder("Test 1").Build(); - var commonTag = new CollectionTagBuilder("Tag 1").Build(); - series.Metadata.CollectionTags.Add(commonTag); - series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build()); - - var series2 = new SeriesBuilder("Test 1").Build(); - series2.Metadata.CollectionTags.Add(commonTag); - library.Series.Add(series); - library.Series.Add(series2); - _unitOfWork.LibraryRepository.Add(library); - await _unitOfWork.CommitAsync(); - - Assert.Equal(2, series.Metadata.CollectionTags.Count); - Assert.Single(series2.Metadata.CollectionTags); - - // Delete both series - _unitOfWork.SeriesRepository.Remove(series); - _unitOfWork.SeriesRepository.Remove(series2); - - await _unitOfWork.CommitAsync(); - - // Validate that both tags exist - Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); - - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); - - Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); - } - - [Fact] - public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags() - { - var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); - var series = new SeriesBuilder("Test 1").Build(); - var commonTag = new CollectionTagBuilder("Tag 1").Build(); - series.Metadata.CollectionTags.Add(commonTag); - series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build()); - - var series2 = new SeriesBuilder("Test 1").Build(); - series2.Metadata.CollectionTags.Add(commonTag); - library.Series.Add(series); - library.Series.Add(series2); - _unitOfWork.LibraryRepository.Add(library); - await _unitOfWork.CommitAsync(); - - Assert.Equal(2, series.Metadata.CollectionTags.Count); - Assert.Single(series2.Metadata.CollectionTags); - - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); - - // Validate that both tags exist - Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); - } - - #endregion + // #region RemoveTagsWithoutSeries + // + // [Fact] + // public async Task RemoveTagsWithoutSeries_ShouldRemoveTags() + // { + // var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); + // var series = new SeriesBuilder("Test 1").Build(); + // var commonTag = new AppUserCollectionBuilder("Tag 1").Build(); + // series.Metadata.CollectionTags.Add(commonTag); + // series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build()); + // + // var series2 = new SeriesBuilder("Test 1").Build(); + // series2.Metadata.CollectionTags.Add(commonTag); + // library.Series.Add(series); + // library.Series.Add(series2); + // _unitOfWork.LibraryRepository.Add(library); + // await _unitOfWork.CommitAsync(); + // + // Assert.Equal(2, series.Metadata.CollectionTags.Count); + // Assert.Single(series2.Metadata.CollectionTags); + // + // // Delete both series + // _unitOfWork.SeriesRepository.Remove(series); + // _unitOfWork.SeriesRepository.Remove(series2); + // + // await _unitOfWork.CommitAsync(); + // + // // Validate that both tags exist + // Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); + // + // await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + // + // Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); + // } + // + // [Fact] + // public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags() + // { + // var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); + // var series = new SeriesBuilder("Test 1").Build(); + // var commonTag = new AppUserCollectionBuilder("Tag 1").Build(); + // series.Metadata.CollectionTags.Add(commonTag); + // series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build()); + // + // var series2 = new SeriesBuilder("Test 1").Build(); + // series2.Metadata.CollectionTags.Add(commonTag); + // library.Series.Add(series); + // library.Series.Add(series2); + // _unitOfWork.LibraryRepository.Add(library); + // await _unitOfWork.CommitAsync(); + // + // Assert.Equal(2, series.Metadata.CollectionTags.Count); + // Assert.Single(series2.Metadata.CollectionTags); + // + // await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + // + // // Validate that both tags exist + // Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); + // } + // + // #endregion } diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index cc00a4484..2ebee8d1d 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -167,53 +167,53 @@ public class CleanupServiceTests : AbstractDbTest } #endregion - #region DeleteTagCoverImages - - [Fact] - public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles() - { - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData("")); - - // Delete all Series to reset state - await ResetDb(); - - // Add 2 series with cover images - - _context.Series.Add(new SeriesBuilder("Test 1") - .WithMetadata(new SeriesMetadataBuilder() - .WithCollectionTag(new CollectionTagBuilder("Something") - .WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg") - .Build()) - .Build()) - .WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg") - .WithLibraryId(1) - .Build()); - - _context.Series.Add(new SeriesBuilder("Test 2") - .WithMetadata(new SeriesMetadataBuilder() - .WithCollectionTag(new CollectionTagBuilder("Something") - .WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg") - .Build()) - .Build()) - .WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg") - .WithLibraryId(1) - .Build()); - - - await _context.SaveChangesAsync(); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, - ds); - - await cleanupService.DeleteTagCoverImages(); - - Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count()); - } - - #endregion + // #region DeleteTagCoverImages + // + // [Fact] + // public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles() + // { + // var filesystem = CreateFileSystem(); + // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData("")); + // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData("")); + // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData("")); + // + // // Delete all Series to reset state + // await ResetDb(); + // + // // Add 2 series with cover images + // + // _context.Series.Add(new SeriesBuilder("Test 1") + // .WithMetadata(new SeriesMetadataBuilder() + // .WithCollectionTag(new AppUserCollectionBuilder("Something") + // .WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg") + // .Build()) + // .Build()) + // .WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg") + // .WithLibraryId(1) + // .Build()); + // + // _context.Series.Add(new SeriesBuilder("Test 2") + // .WithMetadata(new SeriesMetadataBuilder() + // .WithCollectionTag(new AppUserCollectionBuilder("Something") + // .WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg") + // .Build()) + // .Build()) + // .WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg") + // .WithLibraryId(1) + // .Build()); + // + // + // await _context.SaveChangesAsync(); + // var ds = new DirectoryService(Substitute.For>(), filesystem); + // var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + // ds); + // + // await cleanupService.DeleteTagCoverImages(); + // + // Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count()); + // } + // + // #endregion #region DeleteReadingListCoverImages [Fact] @@ -435,24 +435,26 @@ public class CleanupServiceTests : AbstractDbTest [Fact] public async Task CleanupDbEntries_RemoveTagsWithoutSeries() { - var c = new CollectionTag() + var s = new SeriesBuilder("Test") + .WithFormat(MangaFormat.Epub) + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test LIb").Build(); + _context.Series.Add(s); + + var c = new AppUserCollection() { Title = "Test Tag", NormalizedTitle = "Test Tag".ToNormalized(), + AgeRating = AgeRating.Unknown, + Items = new List() {s} }; - var s = new SeriesBuilder("Test") - .WithFormat(MangaFormat.Epub) - .WithMetadata(new SeriesMetadataBuilder().WithCollectionTag(c).Build()) - .Build(); - s.Library = new LibraryBuilder("Test LIb").Build(); - - _context.Series.Add(s); _context.AppUser.Add(new AppUser() { - UserName = "majora2007" + UserName = "majora2007", + Collections = new List() {c} }); - await _context.SaveChangesAsync(); var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, @@ -465,7 +467,7 @@ public class CleanupServiceTests : AbstractDbTest await cleanupService.CleanupDbEntries(); - Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); + Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()); } #endregion diff --git a/API.Tests/Services/CollectionTagServiceTests.cs b/API.Tests/Services/CollectionTagServiceTests.cs index c06767ed1..85e8391fe 100644 --- a/API.Tests/Services/CollectionTagServiceTests.cs +++ b/API.Tests/Services/CollectionTagServiceTests.cs @@ -3,13 +3,13 @@ using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; -using API.DTOs.CollectionTags; +using API.DTOs.Collection; using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; +using API.Services.Plus; using API.SignalR; -using API.Tests.Helpers; using NSubstitute; using Xunit; @@ -25,7 +25,7 @@ public class CollectionTagServiceTests : AbstractDbTest protected override async Task ResetDb() { - _context.CollectionTag.RemoveRange(_context.CollectionTag.ToList()); + _context.AppUserCollection.RemoveRange(_context.AppUserCollection.ToList()); _context.Library.RemoveRange(_context.Library.ToList()); await _unitOfWork.CommitAsync(); @@ -33,119 +33,148 @@ public class CollectionTagServiceTests : AbstractDbTest private async Task SeedSeries() { - if (_context.CollectionTag.Any()) return; + if (_context.AppUserCollection.Any()) return; + var s1 = new SeriesBuilder("Series 1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()).Build(); + var s2 = new SeriesBuilder("Series 2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()).Build(); _context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga) - .WithSeries(new SeriesBuilder("Series 1").Build()) - .WithSeries(new SeriesBuilder("Series 2").Build()) + .WithSeries(s1) + .WithSeries(s2) .Build()); - _context.CollectionTag.Add(new CollectionTagBuilder("Tag 1").Build()); - _context.CollectionTag.Add(new CollectionTagBuilder("Tag 2").WithIsPromoted(true).Build()); + var user = new AppUserBuilder("majora2007", "majora2007", Seed.DefaultThemes.First()).Build(); + user.Collections = new List() + { + new AppUserCollectionBuilder("Tag 1").WithItems(new []{s1}).Build(), + new AppUserCollectionBuilder("Tag 2").WithItems(new []{s1, s2}).WithIsPromoted(true).Build() + }; + _unitOfWork.UserRepository.Add(user); + await _unitOfWork.CommitAsync(); } - - [Fact] - public async Task TagExistsByName_ShouldFindTag() - { - await SeedSeries(); - Assert.True(await _service.TagExistsByName("Tag 1")); - Assert.True(await _service.TagExistsByName("tag 1")); - Assert.False(await _service.TagExistsByName("tag5")); - } + #region UpdateTag [Fact] public async Task UpdateTag_ShouldUpdateFields() { await SeedSeries(); - _context.CollectionTag.Add(new CollectionTagBuilder("UpdateTag_ShouldUpdateFields").WithId(3).WithIsPromoted(true).Build()); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldUpdateFields").WithIsPromoted(true).Build()); + _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); - await _service.UpdateTag(new CollectionTagDto() + await _service.UpdateTag(new AppUserCollectionDto() { Title = "UpdateTag_ShouldUpdateFields", Id = 3, Promoted = true, Summary = "Test Summary", - }); + AgeRating = AgeRating.Unknown + }, 1); - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(3); + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3); Assert.NotNull(tag); Assert.True(tag.Promoted); - Assert.True(!string.IsNullOrEmpty(tag.Summary)); + Assert.False(string.IsNullOrEmpty(tag.Summary)); } + /// + /// UpdateTag should not change any title if non-Kavita source + /// [Fact] - public async Task AddTagToSeries_ShouldAddTagToAllSeries() + public async Task UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource() { await SeedSeries(); - var ids = new[] {1, 2}; - await _service.AddTagToSeries(await _unitOfWork.CollectionTagRepository.GetTagAsync(1, CollectionTagIncludes.SeriesMetadata), ids); - var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(ids); - Assert.Contains(metadatas.ElementAt(0).CollectionTags, t => t.Title.Equals("Tag 1")); - Assert.Contains(metadatas.ElementAt(1).CollectionTags, t => t.Title.Equals("Tag 1")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource").WithSource(ScrobbleProvider.Mal).Build()); + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "New Title", + Id = 3, + Promoted = true, + Summary = "Test Summary", + AgeRating = AgeRating.Unknown + }, 1); + + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3); + Assert.NotNull(tag); + Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title); + Assert.False(string.IsNullOrEmpty(tag.Summary)); + } + #endregion + + + #region RemoveTagFromSeries + + [Fact] + public async Task RemoveTagFromSeries_RemoveSeriesFromTag() + { + await SeedSeries(); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Tag 2 has 2 series + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + + await _service.RemoveTagFromSeries(tag, new[] {1}); + var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.Equal(2, userCollections!.Collections.Count); + Assert.Equal(1, tag.Items.Count); + Assert.Equal(2, tag.Items.First().Id); } + /// + /// Ensure the rating of the tag updates after a series change + /// [Fact] - public async Task RemoveTagFromSeries_ShouldRemoveMultiple() + public async Task RemoveTagFromSeries_RemoveSeriesFromTag_UpdatesRating() { await SeedSeries(); - var ids = new[] {1, 2}; - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(2, CollectionTagIncludes.SeriesMetadata); - await _service.AddTagToSeries(tag, ids); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Tag 2 has 2 series + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); await _service.RemoveTagFromSeries(tag, new[] {1}); - var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1}); - - Assert.Single(metadatas); - Assert.Empty(metadatas.First().CollectionTags); - Assert.NotEmpty(await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {2})); + Assert.Equal(AgeRating.G, tag.AgeRating); } + /// + /// Should remove the tag when there are no items left on the tag + /// [Fact] - public async Task GetTagOrCreate_ShouldReturnNewTag() + public async Task RemoveTagFromSeries_RemoveSeriesFromTag_DeleteTagWhenNoSeriesLeft() { await SeedSeries(); - var tag = await _service.GetTagOrCreate(0, "GetTagOrCreate_ShouldReturnNewTag"); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Tag 1 has 1 series + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); Assert.NotNull(tag); - Assert.Equal(0, tag.Id); - } - - [Fact] - public async Task GetTagOrCreate_ShouldReturnExistingTag() - { - await SeedSeries(); - var tag = await _service.GetTagOrCreate(1, "Some new tag"); - Assert.NotNull(tag); - Assert.Equal(1, tag.Id); - Assert.Equal("Tag 1", tag.Title); - } - - [Fact] - public async Task RemoveTagsWithoutSeries_ShouldRemoveAbandonedEntries() - { - await SeedSeries(); - // Setup a tag with one series - var tag = await _service.GetTagOrCreate(0, "Tag with a series"); - await _unitOfWork.CommitAsync(); - - var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1}); - tag.SeriesMetadatas.Add(metadatas.First()); - var tagId = tag.Id; - await _unitOfWork.CommitAsync(); - - // Validate it doesn't remove tags it shouldn't - await _service.RemoveTagsWithoutSeries(); - Assert.NotNull(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId)); await _service.RemoveTagFromSeries(tag, new[] {1}); - - // Validate it does remove tags it should - await _service.RemoveTagsWithoutSeries(); - Assert.Null(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId)); + var tag2 = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(tag2); } + + #endregion + } diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 996358c38..0ef875e06 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -768,7 +768,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, Genres = new List {new GenreTagDto {Id = 0, Title = "New Genre"}} }, - CollectionTags = new List() + }); Assert.True(success); @@ -777,46 +777,6 @@ public class SeriesServiceTests : AbstractDbTest Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); - - } - - [Fact] - public async Task UpdateSeriesMetadata_ShouldCreateNewTags_IfNoneExist() - { - await ResetDb(); - var s = new SeriesBuilder("Test") - .Build(); - s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - - _context.Series.Add(s); - await _context.SaveChangesAsync(); - - var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto - { - SeriesMetadata = new SeriesMetadataDto - { - SeriesId = 1, - Genres = new List {new GenreTagDto {Id = 0, Title = "New Genre"}}, - Tags = new List {new TagDto {Id = 0, Title = "New Tag"}}, - Characters = new List {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Character}}, - Colorists = new List {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Colorist}}, - Pencillers = new List {new PersonDto {Id = 0, Name = "Joe Shmo 2", Role = PersonRole.Penciller}}, - }, - CollectionTags = new List - { - new CollectionTagDto {Id = 0, Promoted = false, Summary = string.Empty, CoverImageLocked = false, Title = "New Collection"} - } - }); - - Assert.True(success); - - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); - Assert.NotNull(series.Metadata); - Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); - Assert.True(series.Metadata.People.All(g => g.Name is "Joe Shmo" or "Joe Shmo 2")); - Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(g => g.Title)); - Assert.Contains("New Collection", series.Metadata.CollectionTags.Select(g => g.Title)); - } [Fact] @@ -842,7 +802,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, Genres = new List {new () {Id = 0, Title = "New Genre"}}, }, - CollectionTags = new List() + }); Assert.True(success); @@ -875,7 +835,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, Publishers = new List {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, }, - CollectionTags = new List() + }); Assert.True(success); @@ -911,7 +871,7 @@ public class SeriesServiceTests : AbstractDbTest Publishers = new List {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, PublisherLocked = true }, - CollectionTags = new List() + }); Assert.True(success); @@ -944,7 +904,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, Publishers = new List(), }, - CollectionTags = new List() + }); Assert.True(success); @@ -978,7 +938,7 @@ public class SeriesServiceTests : AbstractDbTest Genres = new List {new () {Id = 1, Title = "Existing Genre"}}, GenresLocked = true }, - CollectionTags = new List() + }); Assert.True(success); @@ -1007,7 +967,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, ReleaseYear = 100, }, - CollectionTags = new List() + }); Assert.True(success); diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index de2cf0394..1be979a56 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -40,8 +40,14 @@ public static class PolicyConstants /// /// This is used explicitly for Demo Server. Not sure why it would be used in another fashion public const string ReadOnlyRole = "Read Only"; + /// + /// Ability to promote entities (Collections, Reading Lists, etc). + /// + public const string PromoteRole = "Promote"; + + public static readonly ImmutableArray ValidRoles = - ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole); + ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole, PromoteRole); } diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index a019255a8..bfc6849d6 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -1,16 +1,18 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs.Collection; using API.DTOs.CollectionTags; -using API.Entities.Metadata; +using API.Entities; using API.Extensions; +using API.Helpers.Builders; using API.Services; using API.Services.Plus; using Kavita.Common; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; @@ -38,50 +40,37 @@ public class CollectionController : BaseApiController } /// - /// Return a list of all collection tags on the server for the logged in user. + /// Returns all Collection tags for a given User /// /// [HttpGet] - public async Task>> GetAllTags() + public async Task>> GetAllTags(bool ownedOnly = false) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user == null) return Unauthorized(); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - if (isAdmin) - { - return Ok(await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()); - } - - return Ok(await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(user.Id)); + return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), !ownedOnly)); } /// - /// Searches against the collection tags on the DB and returns matches that meet the search criteria. - /// Search strings will be cleaned of certain fields, like % + /// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned) /// - /// Search term + /// + /// /// - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("search")] - public async Task>> SearchTags(string? queryString) + [HttpGet("all-series")] + public async Task>> GetCollectionsBySeries(int seriesId, bool ownedOnly = false) { - queryString ??= string.Empty; - queryString = queryString.Replace(@"%", string.Empty); - if (queryString.Length == 0) return await GetAllTags(); - - return Ok(await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId())); + return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosBySeriesAsync(User.GetUserId(), seriesId, !ownedOnly)); } + /// /// Checks if a collection exists with the name /// /// If empty or null, will return true as that is invalid /// - [Authorize(Policy = "RequireAdminRole")] [HttpGet("name-exists")] public async Task> DoesNameExists(string name) { - return Ok(await _collectionService.TagExistsByName(name)); + return Ok(await _unitOfWork.CollectionTagRepository.CollectionExists(name, User.GetUserId())); } /// @@ -90,13 +79,15 @@ public class CollectionController : BaseApiController /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpPost("update")] - public async Task UpdateTag(CollectionTagDto updatedTag) + public async Task UpdateTag(AppUserCollectionDto updatedTag) { try { - if (await _collectionService.UpdateTag(updatedTag)) return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully")); + if (await _collectionService.UpdateTag(updatedTag, User.GetUserId())) + { + return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully")); + } } catch (KavitaException ex) { @@ -107,18 +98,94 @@ public class CollectionController : BaseApiController } /// - /// Adds a collection tag onto multiple Series. If tag id is 0, this will create a new tag. + /// Promote/UnPromote multiple collections in one go. Will only update the authenticated user's collections and will only work if the user has promotion role + /// + /// + /// + [HttpPost("promote-multiple")] + public async Task PromoteMultipleCollections(PromoteCollectionsDto dto) + { + // This needs to take into account owner as I can select other users cards + var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds); + var userId = User.GetUserId(); + + if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole)) + { + return BadRequest(await _localizationService.Translate(userId, "permission-denied")); + } + + foreach (var collection in collections) + { + if (collection.AppUserId != userId) continue; + collection.Promoted = dto.Promoted; + _unitOfWork.CollectionTagRepository.Update(collection); + } + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + + + /// + /// Promote/UnPromote multiple collections in one go + /// + /// + /// + [HttpPost("delete-multiple")] + public async Task DeleteMultipleCollections(PromoteCollectionsDto dto) + { + // This needs to take into account owner as I can select other users cards + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); + if (user == null) return Unauthorized(); + user.Collections = user.Collections.Where(uc => !dto.CollectionIds.Contains(uc.Id)).ToList(); + _unitOfWork.UserRepository.Update(user); + + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + + /// + /// Adds multiple series to a collection. If tag id is 0, this will create a new tag. /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpPost("update-for-series")] public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto) { // Create a new tag and save - var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); + if (user == null) return Unauthorized(); - if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok(); + AppUserCollection? tag; + if (dto.CollectionTagId == 0) + { + tag = new AppUserCollectionBuilder(dto.CollectionTagTitle).Build(); + user.Collections.Add(tag); + } + else + { + // Validate tag doesn't exist + tag = user.Collections.FirstOrDefault(t => t.Id == dto.CollectionTagId); + } + + if (tag == null) + { + return BadRequest(_localizationService.Translate(User.GetUserId(), "collection-doesnt-exists")); + } + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList()); + foreach (var s in series) + { + if (tag.Items.Contains(s)) continue; + tag.Items.Add(s); + } + _unitOfWork.UserRepository.Update(user); + if (await _unitOfWork.CommitAsync()) return Ok(); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } @@ -128,13 +195,12 @@ public class CollectionController : BaseApiController /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpPost("update-series")] public async Task RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto) { try { - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata); + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(updateSeriesForTagDto.Tag.Id, CollectionIncludes.Series); if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove)) @@ -149,24 +215,28 @@ public class CollectionController : BaseApiController } /// - /// Removes the collection tag from all Series it was attached to + /// Removes the collection tag from the user /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpDelete] public async Task DeleteTag(int tagId) { try { - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata); - if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); + if (user == null) return Unauthorized(); + if (user.Collections.All(c => c.Id != tagId)) + return BadRequest(await _localizationService.Translate(user.Id, "access-denied")); - if (await _collectionService.DeleteTag(tag)) + if (await _collectionService.DeleteTag(tagId, user)) + { return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted")); + } } - catch (Exception) + catch (Exception ex) { + await _unitOfWork.RollbackAsync(); } diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 837ad999c..b7212c7f3 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -111,7 +111,7 @@ public class ImageController : BaseApiController } /// - /// Returns cover image for Collection Tag + /// Returns cover image for Collection /// /// /// @@ -121,6 +121,7 @@ public class ImageController : BaseApiController { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == 0) return BadRequest(); + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) { diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 9769fef81..fac50229e 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -9,6 +9,7 @@ using API.Comparators; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; @@ -450,15 +451,13 @@ public class OpdsController : BaseApiController var userId = await GetUser(apiKey); if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - var (baseUrl, prefix) = await GetPrefix(); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - - var tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()) - : (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId)); + var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true); + var (baseUrl, prefix) = await GetPrefix(); var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix); SetFeedId(feed, "collections"); @@ -467,12 +466,15 @@ public class OpdsController : BaseApiController Id = tag.Id.ToString(), Title = tag.Title, Summary = tag.Summary, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}") - } + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/collections/{tag.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}") + ] })); return CreateXmlResult(SerializeXml(feed)); @@ -489,20 +491,9 @@ public class OpdsController : BaseApiController var (baseUrl, prefix) = await GetPrefix(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - IEnumerable tags; - if (isAdmin) - { - tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); - } - else - { - tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId); - } - - var tag = tags.SingleOrDefault(t => t.Id == collectionId); - if (tag == null) + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId); + if (tag == null || (tag.AppUserId != user.Id && !tag.Promoted)) { return BadRequest("Collection does not exist or you don't have access"); } diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 4ce7d282d..e01628dbd 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -58,7 +58,7 @@ public class SearchController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); - if (!libraries.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); + if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 7fcb0a95c..2430064c8 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -147,7 +147,7 @@ public class UploadController : BaseApiController try { - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id); + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadFileDto.Id); if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index b80607b56..071a027f7 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -40,6 +40,7 @@ public class WantToReadController : BaseApiController /// /// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2) /// + /// This will be removed in v0.8.x /// /// /// diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs new file mode 100644 index 000000000..62d786ca2 --- /dev/null +++ b/API/DTOs/Collection/AppUserCollectionDto.cs @@ -0,0 +1,39 @@ +using System; +using API.Entities.Enums; +using API.Services.Plus; + +namespace API.DTOs.Collection; +#nullable enable + +public class AppUserCollectionDto +{ + public int Id { get; init; } + public string Title { get; set; } = default!; + public string Summary { get; set; } = default!; + public bool Promoted { get; set; } + public AgeRating AgeRating { get; set; } + + /// + /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. + /// + public string? CoverImage { get; set; } = string.Empty; + public bool CoverImageLocked { get; set; } + + /// + /// Owner of the Collection + /// + public string? Owner { get; set; } + + /// + /// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections) + /// + public DateTime LastSyncUtc { get; set; } + /// + /// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote + /// + public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita; + /// + /// For Non-Kavita sourced collections, the url to sync from + /// + public string? SourceUrl { get; set; } +} diff --git a/API/DTOs/Collection/DeleteCollectionsDto.cs b/API/DTOs/Collection/DeleteCollectionsDto.cs new file mode 100644 index 000000000..66bf257ba --- /dev/null +++ b/API/DTOs/Collection/DeleteCollectionsDto.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace API.DTOs.Collection; + +public class DeleteCollectionsDto +{ + public IList CollectionIds { get; set; } +} diff --git a/API/DTOs/Collection/PromoteCollectionsDto.cs b/API/DTOs/Collection/PromoteCollectionsDto.cs new file mode 100644 index 000000000..2e2ab793b --- /dev/null +++ b/API/DTOs/Collection/PromoteCollectionsDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace API.DTOs.Collection; + +public class PromoteCollectionsDto +{ + public IList CollectionIds { get; init; } + public bool Promoted { get; init; } +} diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index f8791b0d6..f4961ac27 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -1,6 +1,7 @@ using System; namespace API.DTOs.ReadingLists; +#nullable enable public class ReadingListDto { @@ -15,7 +16,7 @@ public class ReadingListDto /// /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. /// - public string CoverImage { get; set; } = string.Empty; + public string? CoverImage { get; set; } = string.Empty; /// /// Minimum Year the Reading List starts /// diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index eb47579f1..f7a622664 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Metadata; using API.DTOs.Reader; @@ -13,7 +14,7 @@ public class SearchResultGroupDto { public IEnumerable Libraries { get; set; } = default!; public IEnumerable Series { get; set; } = default!; - public IEnumerable Collections { get; set; } = default!; + public IEnumerable Collections { get; set; } = default!; public IEnumerable ReadingLists { get; set; } = default!; public IEnumerable Persons { get; set; } = default!; public IEnumerable Genres { get; set; } = default!; diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index f9349bed1..3f344dff5 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using API.DTOs.CollectionTags; using API.DTOs.Metadata; using API.Entities.Enums; @@ -10,11 +9,6 @@ public class SeriesMetadataDto public int Id { get; set; } public string Summary { get; set; } = string.Empty; - /// - /// Collections the Series belongs to - /// - public ICollection CollectionTags { get; set; } = new List(); - /// /// Genres for the Series /// diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs index 43318fe0f..719a9459a 100644 --- a/API/DTOs/UpdateSeriesMetadataDto.cs +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -1,11 +1,6 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using API.DTOs.CollectionTags; - -namespace API.DTOs; +namespace API.DTOs; public class UpdateSeriesMetadataDto { public SeriesMetadataDto SeriesMetadata { get; set; } = default!; - public ICollection CollectionTags { get; set; } = default!; } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index b4c95fe82..4af165249 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -36,6 +36,7 @@ public sealed class DataContext : IdentityDbContext ServerSetting { get; set; } = null!; public DbSet AppUserPreferences { get; set; } = null!; public DbSet SeriesMetadata { get; set; } = null!; + [Obsolete] public DbSet CollectionTag { get; set; } = null!; public DbSet AppUserBookmark { get; set; } = null!; public DbSet ReadingList { get; set; } = null!; @@ -64,6 +65,7 @@ public sealed class DataContext : IdentityDbContext ExternalRecommendation { get; set; } = null!; public DbSet ManualMigrationHistory { get; set; } = null!; public DbSet SeriesBlacklist { get; set; } = null!; + public DbSet AppUserCollection { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) @@ -149,6 +151,10 @@ public sealed class DataContext : IdentityDbContext s.ExternalSeriesMetadata) .HasForeignKey(em => em.SeriesId) .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.AgeRating) + .HasDefaultValue(AgeRating.Unknown); } #nullable enable diff --git a/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs b/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs new file mode 100644 index 000000000..7204bd0d3 --- /dev/null +++ b/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Extensions.QueryExtensions; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.0 refactored User Collections +/// +public static class MigrateCollectionTagToUserCollections +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateCollectionTagToUserCollections")) + { + return; + } + + logger.LogCritical( + "Running MigrateCollectionTagToUserCollections migration - Please be patient, this may take some time. This is not an error"); + + // Find the first user that is an admin + var defaultAdmin = await unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections); + if (defaultAdmin == null) + { + await CompleteMigration(dataContext, logger); + return; + } + + // For all collectionTags, move them over to said user + var existingCollections = await dataContext.CollectionTag + .OrderBy(c => c.NormalizedTitle) + .Includes(CollectionTagIncludes.SeriesMetadataWithSeries) + .ToListAsync(); + foreach (var existingCollectionTag in existingCollections) + { + var collection = new AppUserCollection() + { + Title = existingCollectionTag.Title, + NormalizedTitle = existingCollectionTag.Title.Normalize(), + CoverImage = existingCollectionTag.CoverImage, + CoverImageLocked = existingCollectionTag.CoverImageLocked, + Promoted = existingCollectionTag.Promoted, + AgeRating = AgeRating.Unknown, + Summary = existingCollectionTag.Summary, + Items = existingCollectionTag.SeriesMetadatas.Select(s => s.Series).ToList() + }; + + collection.AgeRating = await unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(collection.Items.Select(s => s.Id)); + defaultAdmin.Collections.Add(collection); + } + unitOfWork.UserRepository.Update(defaultAdmin); + + await unitOfWork.CommitAsync(); + + await CompleteMigration(dataContext, logger); + } + + private static async Task CompleteMigration(DataContext dataContext, ILogger logger) + { + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateCollectionTagToUserCollections", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + + logger.LogCritical( + "Running MigrateCollectionTagToUserCollections migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs b/API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs new file mode 100644 index 000000000..5527a0fbb --- /dev/null +++ b/API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs @@ -0,0 +1,3019 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20240331172900_UserBasedCollections")] + partial class UserBasedCollections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.3"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20240331172900_UserBasedCollections.cs b/API/Data/Migrations/20240331172900_UserBasedCollections.cs new file mode 100644 index 000000000..c5a376bd8 --- /dev/null +++ b/API/Data/Migrations/20240331172900_UserBasedCollections.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class UserBasedCollections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserCollection", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: true), + NormalizedTitle = table.Column(type: "TEXT", nullable: true), + Summary = table.Column(type: "TEXT", nullable: true), + Promoted = table.Column(type: "INTEGER", nullable: false), + CoverImage = table.Column(type: "TEXT", nullable: true), + CoverImageLocked = table.Column(type: "INTEGER", nullable: false), + AgeRating = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + Created = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + LastModifiedUtc = table.Column(type: "TEXT", nullable: false), + LastSyncUtc = table.Column(type: "TEXT", nullable: false), + Source = table.Column(type: "INTEGER", nullable: false), + SourceUrl = table.Column(type: "TEXT", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserCollection", x => x.Id); + table.ForeignKey( + name: "FK_AppUserCollection_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AppUserCollectionSeries", + columns: table => new + { + CollectionsId = table.Column(type: "INTEGER", nullable: false), + ItemsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserCollectionSeries", x => new { x.CollectionsId, x.ItemsId }); + table.ForeignKey( + name: "FK_AppUserCollectionSeries_AppUserCollection_CollectionsId", + column: x => x.CollectionsId, + principalTable: "AppUserCollection", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserCollectionSeries_Series_ItemsId", + column: x => x.ItemsId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserCollection_AppUserId", + table: "AppUserCollection", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserCollectionSeries_ItemsId", + table: "AppUserCollectionSeries", + column: "ItemsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserCollectionSeries"); + + migrationBuilder.DropTable( + name: "AppUserCollection"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index a67d3819e..24c5e35da 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -189,6 +189,66 @@ namespace API.Data.Migrations b.ToTable("AppUserBookmark"); }); + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => { b.Property("Id") @@ -1918,6 +1978,21 @@ namespace API.Data.Migrations b.ToTable("Volume"); }); + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + modelBuilder.Entity("AppUserLibrary", b => { b.Property("AppUsersId") @@ -2178,6 +2253,17 @@ namespace API.Data.Migrations b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2626,6 +2712,21 @@ namespace API.Data.Migrations b.Navigation("Series"); }); + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("AppUserLibrary", b => { b.HasOne("API.Entities.AppUser", null) @@ -2836,6 +2937,8 @@ namespace API.Data.Migrations { b.Navigation("Bookmarks"); + b.Navigation("Collections"); + b.Navigation("DashboardStreams"); b.Navigation("Devices"); diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index a7c942734..b034eea50 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -3,43 +3,61 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data.Misc; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Extensions.QueryExtensions.Filtering; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable + [Flags] public enum CollectionTagIncludes { None = 1, SeriesMetadata = 2, + SeriesMetadataWithSeries = 4 +} + +[Flags] +public enum CollectionIncludes +{ + None = 1, + Series = 2, } public interface ICollectionTagRepository { - void Add(CollectionTag tag); - void Remove(CollectionTag tag); - Task> GetAllTagDtosAsync(); - Task> SearchTagDtosAsync(string searchQuery, int userId); + void Remove(AppUserCollection tag); Task GetCoverImageAsync(int collectionTagId); - Task> GetAllPromotedTagDtosAsync(int userId); - Task GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None); - void Update(CollectionTag tag); - Task RemoveTagsWithoutSeries(); - Task> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None); + Task GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None); + void Update(AppUserCollection tag); + Task RemoveCollectionsWithoutSeries(); + + Task> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None); + /// + /// Returns all of the user's collections with the option of other user's promoted + /// + /// + /// + /// + Task> GetCollectionDtosAsync(int userId, bool includePromoted = false); + Task> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false); - Task> GetAllTagsByNamesAsync(IEnumerable normalizedTitles, - CollectionTagIncludes includes = CollectionTagIncludes.None); Task> GetAllCoverImagesAsync(); - Task TagExists(string title); - Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); + Task CollectionExists(string title, int userId); + Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task> GetRandomCoverImagesAsync(int collectionId); + Task> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None); + Task UpdateCollectionAgeRating(AppUserCollection tag); + Task> GetCollectionsByIds(IEnumerable tags, CollectionIncludes includes = CollectionIncludes.None); } public class CollectionTagRepository : ICollectionTagRepository { @@ -52,17 +70,12 @@ public class CollectionTagRepository : ICollectionTagRepository _mapper = mapper; } - public void Add(CollectionTag tag) + public void Remove(AppUserCollection tag) { - _context.CollectionTag.Add(tag); + _context.AppUserCollection.Remove(tag); } - public void Remove(CollectionTag tag) - { - _context.CollectionTag.Remove(tag); - } - - public void Update(CollectionTag tag) + public void Update(AppUserCollection tag) { _context.Entry(tag).State = EntityState.Modified; } @@ -70,38 +83,53 @@ public class CollectionTagRepository : ICollectionTagRepository /// /// Removes any collection tags without any series /// - public async Task RemoveTagsWithoutSeries() + public async Task RemoveCollectionsWithoutSeries() { - var tagsToDelete = await _context.CollectionTag - .Include(c => c.SeriesMetadatas) - .Where(c => c.SeriesMetadatas.Count == 0) + var tagsToDelete = await _context.AppUserCollection + .Include(c => c.Items) + .Where(c => c.Items.Count == 0) .AsSplitQuery() .ToListAsync(); + _context.RemoveRange(tagsToDelete); return await _context.SaveChangesAsync(); } - public async Task> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None) + public async Task> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None) { - return await _context.CollectionTag + return await _context.AppUserCollection .OrderBy(c => c.NormalizedTitle) .Includes(includes) .ToListAsync(); } - public async Task> GetAllTagsByNamesAsync(IEnumerable normalizedTitles, CollectionTagIncludes includes = CollectionTagIncludes.None) + public async Task> GetCollectionDtosAsync(int userId, bool includePromoted = false) { - return await _context.CollectionTag - .Where(c => normalizedTitles.Contains(c.NormalizedTitle)) - .OrderBy(c => c.NormalizedTitle) - .Includes(includes) + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + return await _context.AppUserCollection + .Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted)) + .WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating) + .OrderBy(uc => uc.Title) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + return await _context.AppUserCollection + .Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted)) + .Where(uc => uc.Items.Any(s => s.Id == seriesId)) + .WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating) + .OrderBy(uc => uc.Title) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } public async Task GetCoverImageAsync(int collectionTagId) { - return await _context.CollectionTag + return await _context.AppUserCollection .Where(c => c.Id == collectionTagId) .Select(c => c.CoverImage) .SingleOrDefaultAsync(); @@ -109,12 +137,13 @@ public class CollectionTagRepository : ICollectionTagRepository public async Task> GetAllCoverImagesAsync() { - return (await _context.CollectionTag + return await _context.AppUserCollection .Select(t => t.CoverImage) .Where(t => !string.IsNullOrEmpty(t)) - .ToListAsync())!; + .ToListAsync(); } + [Obsolete("use TagExists with userId")] public async Task TagExists(string title) { var normalized = title.ToNormalized(); @@ -122,10 +151,24 @@ public class CollectionTagRepository : ICollectionTagRepository .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); } - public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) + /// + /// If any tag exists for that given user's collections + /// + /// + /// + /// + public async Task CollectionExists(string title, int userId) + { + var normalized = title.ToNormalized(); + return await _context.AppUserCollection + .Where(uc => uc.AppUserId == userId) + .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); + } + + public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) { var extension = encodeFormat.GetExtension(); - return await _context.CollectionTag + return await _context.AppUserCollection .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension)) .ToListAsync(); } @@ -139,12 +182,41 @@ public class CollectionTagRepository : ICollectionTagRepository .Select(sm => sm.Series.CoverImage) .Where(t => !string.IsNullOrEmpty(t)) .ToListAsync(); + return data .OrderBy(_ => random.Next()) .Take(4) .ToList(); } + public async Task> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None) + { + return await _context.AppUserCollection + .Where(c => c.AppUserId == userId) + .Includes(includes) + .ToListAsync(); + } + + public async Task UpdateCollectionAgeRating(AppUserCollection tag) + { + var maxAgeRating = await _context.AppUserCollection + .Where(t => t.Id == tag.Id) + .SelectMany(uc => uc.Items.Select(s => s.Metadata)) + .Select(sm => sm.AgeRating) + .MaxAsync(); + tag.AgeRating = maxAgeRating; + await _context.SaveChangesAsync(); + } + + public async Task> GetCollectionsByIds(IEnumerable tags, CollectionIncludes includes = CollectionIncludes.None) + { + return await _context.AppUserCollection + .Where(c => tags.Contains(c.Id)) + .Includes(includes) + .AsSplitQuery() + .ToListAsync(); + } + public async Task> GetAllTagDtosAsync() { @@ -168,9 +240,9 @@ public class CollectionTagRepository : ICollectionTagRepository } - public async Task GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None) + public async Task GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None) { - return await _context.CollectionTag + return await _context.AppUserCollection .Where(c => c.Id == tagId) .Includes(includes) .AsSplitQuery() @@ -190,16 +262,12 @@ public class CollectionTagRepository : ICollectionTagRepository .SingleAsync(); } - public async Task> SearchTagDtosAsync(string searchQuery, int userId) + public async Task> SearchTagDtosAsync(string searchQuery, int userId) { var userRating = await GetUserAgeRestriction(userId); - return await _context.CollectionTag - .Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%") - || EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%")) - .RestrictAgainstAgeRestriction(userRating) - .OrderBy(s => s.NormalizedTitle) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) + return await _context.AppUserCollection + .Search(searchQuery, userId, userRating) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 9d6d9f694..651a6c642 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -7,6 +7,7 @@ using API.Constants; using API.Data.Misc; using API.Data.Scanner; using API.DTOs; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Dashboard; using API.DTOs.Filtering; @@ -141,7 +142,7 @@ public interface ISeriesRepository MangaFormat format); Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); Task>> GetFolderPathMap(int libraryId); - Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds); + Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds); /// /// This is only used for /// @@ -342,10 +343,7 @@ public class SeriesRepository : ISeriesRepository return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync(); } - return new List() - { - libraryId - }; + return [libraryId]; } public async Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery) @@ -362,12 +360,9 @@ public class SeriesRepository : ISeriesRepository .ToList(); result.Libraries = await _context.Library - .Where(l => libraryIds.Contains(l.Id)) - .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) - .IsRestricted(QueryContext.Search) - .AsSplitQuery() - .OrderBy(l => l.Name.ToLower()) + .Search(searchQuery, userId, libraryIds) .Take(maxRecords) + .OrderBy(l => l.Name.ToLower()) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -419,53 +414,33 @@ public class SeriesRepository : ISeriesRepository result.ReadingLists = await _context.ReadingList - .Where(rl => rl.AppUserId == userId || rl.Promoted) - .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) - .RestrictAgainstAgeRestriction(userRating) - .AsSplitQuery() - .OrderBy(r => r.NormalizedTitle) + .Search(searchQuery, userId, userRating) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Collections = await _context.CollectionTag - .Where(c => (EF.Functions.Like(c.Title, $"%{searchQuery}%")) - || (EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%"))) - .Where(c => c.Promoted || isAdmin) - .RestrictAgainstAgeRestriction(userRating) - .OrderBy(s => s.NormalizedTitle) - .AsSplitQuery() + result.Collections = await _context.AppUserCollection + .Search(searchQuery, userId, userRating) .Take(maxRecords) .OrderBy(c => c.NormalizedTitle) - .ProjectTo(_mapper.ConfigurationProvider) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); result.Persons = await _context.SeriesMetadata - .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%"))) - .AsSplitQuery() - .Distinct() - .OrderBy(p => p.NormalizedName) + .SearchPeople(searchQuery, seriesIds) .Take(maxRecords) + .OrderBy(t => t.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); result.Genres = await _context.SeriesMetadata - .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) - .AsSplitQuery() - .Distinct() - .OrderBy(t => t.NormalizedTitle) + .SearchGenres(searchQuery, seriesIds) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); result.Tags = await _context.SeriesMetadata - .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) - .AsSplitQuery() - .Distinct() - .OrderBy(t => t.NormalizedTitle) + .SearchTags(searchQuery, seriesIds) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -740,6 +715,7 @@ public class SeriesRepository : ISeriesRepository .FirstOrDefaultAsync(); } + public async Task AddSeriesModifiers(int userId, IList series) { var userProgress = await _context.AppUserProgresses @@ -968,6 +944,20 @@ public class SeriesRepository : ISeriesRepository out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter); + IList collectionSeries = []; + if (hasCollectionTagFilter) + { + collectionSeries = await _context.AppUserCollection + .Where(uc => uc.Promoted || uc.AppUserId == userId) + .Where(uc => filter.CollectionTags.Contains(uc.Id)) + .SelectMany(uc => uc.Items) + .RestrictAgainstAgeRestriction(userRating) + .Select(s => s.Id) + .Distinct() + .ToListAsync(); + } + + var query = _context.Series .AsNoTracking() // This new style can handle any filterComparision coming from the user @@ -979,7 +969,7 @@ public class SeriesRepository : ISeriesRepository .HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating) .HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus) .HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags) - .HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags) + .HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags, collectionSeries) .HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres) .HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!) .HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0) @@ -1045,6 +1035,8 @@ public class SeriesRepository : ISeriesRepository .Select(u => u.CollapseSeriesRelationships) .SingleOrDefaultAsync(); + + query ??= _context.Series .AsNoTracking(); @@ -1062,6 +1054,9 @@ public class SeriesRepository : ISeriesRepository query = ApplyWantToReadFilter(filter, query, userId); + query = await ApplyCollectionFilter(filter, query, userId, userRating); + + query = BuildFilterQuery(userId, filter, query); @@ -1078,6 +1073,50 @@ public class SeriesRepository : ISeriesRepository .AsSplitQuery(), filter.LimitTo); } + private async Task> ApplyCollectionFilter(FilterV2Dto filter, IQueryable query, int userId, AgeRestriction userRating) + { + var collectionStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.CollectionTags); + if (collectionStmt == null) return query; + + var value = (IList) FilterFieldValueConverter.ConvertValue(collectionStmt.Field, collectionStmt.Value); + + if (value.Count == 0) + { + return query; + } + + var collectionSeries = await _context.AppUserCollection + .Where(uc => uc.Promoted || uc.AppUserId == userId) + .Where(uc => value.Contains(uc.Id)) + .SelectMany(uc => uc.Items) + .RestrictAgainstAgeRestriction(userRating) + .Select(s => s.Id) + .Distinct() + .ToListAsync(); + + if (collectionStmt.Comparison != FilterComparison.MustContains) + return query.HasCollectionTags(true, collectionStmt.Comparison, value, collectionSeries); + + var collectionSeriesTasks = value.Select(async collectionId => + { + return await _context.AppUserCollection + .Where(uc => uc.Promoted || uc.AppUserId == userId) + .Where(uc => uc.Id == collectionId) + .SelectMany(uc => uc.Items) + .RestrictAgainstAgeRestriction(userRating) + .Select(s => s.Id) + .ToListAsync(); + }); + + var collectionSeriesLists = await Task.WhenAll(collectionSeriesTasks); + + // Find the common series among all collections + var commonSeries = collectionSeriesLists.Aggregate((common, next) => common.Intersect(next).ToList()); + + // Filter the original query based on the common series + return query.Where(s => commonSeries.Contains(s.Id)); + } + private IQueryable ApplyWantToReadFilter(FilterV2Dto filter, IQueryable query, int userId) { var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead); @@ -1175,7 +1214,6 @@ public class SeriesRepository : ISeriesRepository FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList) value), FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId), FilterField.Tags => query.HasTags(true, statement.Comparison, (IList) value), - FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList) value), FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList) value), FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList) value), FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList) value), @@ -1190,6 +1228,9 @@ public class SeriesRepository : ISeriesRepository FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList) value), FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList) value), FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList) value), + FilterField.CollectionTags => + // This is handled in the code before this as it's handled in a more general, combined manner + query, FilterField.Libraries => // This is handled in the code before this as it's handled in a more general, combined manner query, @@ -1241,7 +1282,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesMetadata(int seriesId) { - var metadataDto = await _context.SeriesMetadata + return await _context.SeriesMetadata .Where(metadata => metadata.SeriesId == seriesId) .Include(m => m.Genres.OrderBy(g => g.NormalizedTitle)) .Include(m => m.Tags.OrderBy(g => g.NormalizedTitle)) @@ -1250,42 +1291,20 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() .SingleOrDefaultAsync(); - - if (metadataDto != null) - { - metadataDto.CollectionTags = await _context.CollectionTag - .Include(t => t.SeriesMetadatas) - .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId)) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .OrderBy(t => t.Title.ToLower()) - .AsSplitQuery() - .ToListAsync(); - } - - return metadataDto; } public async Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams) { - var userLibraries = _context.Library - .Include(l => l.AppUsers) - .Where(library => library.AppUsers.Any(user => user.Id == userId)) - .AsSplitQuery() - .AsNoTracking() - .Select(library => library.Id) - .ToList(); + var userLibraries = _context.Library.GetUserLibraries(userId); - var query = _context.CollectionTag + var query = _context.AppUserCollection .Where(s => s.Id == collectionId) - .Include(c => c.SeriesMetadatas) - .ThenInclude(m => m.Series) - .SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId))) + .Include(c => c.Items) + .SelectMany(c => c.Items.Where(s => userLibraries.Contains(s.LibraryId))) .OrderBy(s => s.LibraryId) .ThenBy(s => s.SortName.ToLower()) .ProjectTo(_mapper.ConfigurationProvider) - .AsSplitQuery() - .AsNoTracking(); + .AsSplitQuery(); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } @@ -2072,18 +2091,20 @@ public class SeriesRepository : ISeriesRepository } /// - /// Returns the highest Age Rating for a list of Series + /// Returns the highest Age Rating for a list of Series. Defaults to /// /// /// - public async Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds) + public async Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds) { - return await _context.Series + var ret = await _context.Series .Where(s => seriesIds.Contains(s.Id)) .Include(s => s.Metadata) .Select(s => s.Metadata.AgeRating) .OrderBy(s => s) .LastOrDefaultAsync(); + if (ret == null) return AgeRating.Unknown; + return ret; } /// diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 9515a3f11..07723bf1b 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -38,7 +38,8 @@ public enum AppUserIncludes SmartFilters = 1024, DashboardStreams = 2048, SideNavStreams = 4096, - ExternalSources = 8192 // 2^13 + ExternalSources = 8192, + Collections = 16384 // 2^14 } public interface IUserRepository @@ -57,6 +58,7 @@ public interface IUserRepository Task> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true); Task> GetAdminUsersAsync(); Task IsUserAdminAsync(AppUser? user); + Task> GetRoles(int userId); Task GetUserRatingAsync(int seriesId, int userId); Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId); Task GetPreferencesAsync(string username); @@ -78,7 +80,7 @@ public interface IUserRepository Task HasAccessToSeries(int userId, int seriesId); Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserByConfirmationToken(string token); - Task GetDefaultAdminUser(); + Task GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None); Task> GetSeriesWithRatings(int userId); Task> GetSeriesWithReviews(int userId); Task HasHoldOnSeries(int userId, int seriesId); @@ -298,11 +300,13 @@ public class UserRepository : IUserRepository /// Returns the first admin account created /// /// - public async Task GetDefaultAdminUser() + public async Task GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None) { - return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole)) + return await _context.AppUser + .Includes(includes) + .Where(u => u.UserRoles.Any(r => r.Role.Name == PolicyConstants.AdminRole)) .OrderBy(u => u.Created) - .First(); + .FirstAsync(); } public async Task> GetSeriesWithRatings(int userId) @@ -482,7 +486,7 @@ public class UserRepository : IUserRepository public async Task> GetAdminUsersAsync() { - return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); + return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole)).OrderBy(u => u.CreatedUtc); } public async Task IsUserAdminAsync(AppUser? user) @@ -491,6 +495,14 @@ public class UserRepository : IUserRepository return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); } + public async Task> GetRoles(int userId) + { + var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId); + if (user == null || _userManager == null) return ArraySegment.Empty; // userManager is null on Unit Tests only + + return await _userManager.GetRolesAsync(user); + } + public async Task GetUserRatingAsync(int seriesId, int userId) { return await _context.AppUserRating diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index e9eb0cbe3..2e6f42d3d 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -29,6 +29,10 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// public ICollection ReadingLists { get; set; } = null!; /// + /// Collections associated with this user + /// + public ICollection Collections { get; set; } = null!; + /// /// A list of Series the user want's to read /// public ICollection WantToRead { get; set; } = null!; diff --git a/API/Entities/AppUserCollection.cs b/API/Entities/AppUserCollection.cs new file mode 100644 index 000000000..6ae0bca1c --- /dev/null +++ b/API/Entities/AppUserCollection.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; +using API.Entities.Interfaces; +using API.Services.Plus; + + +namespace API.Entities; + +/// +/// Represents a Collection of Series for a given User +/// +public class AppUserCollection : IEntityDate +{ + public int Id { get; set; } + public required string Title { get; set; } + /// + /// A normalized string used to check if the collection already exists in the DB + /// + public required string NormalizedTitle { get; set; } + public string? Summary { get; set; } + /// + /// Reading lists that are promoted are only done by admins + /// + public bool Promoted { get; set; } + /// + /// Path to the (managed) image file + /// + /// The file is managed internally to Kavita's APPDIR + public string? CoverImage { get; set; } + public bool CoverImageLocked { get; set; } + /// + /// The highest age rating from all Series within the collection + /// + public required AgeRating AgeRating { get; set; } = AgeRating.Unknown; + public ICollection Items { get; set; } = null!; + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + + // Sync stuff for Kavita+ + /// + /// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections) + /// + public DateTime LastSyncUtc { get; set; } + /// + /// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote + /// + public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita; + /// + /// For Non-Kavita sourced collections, the url to sync from + /// + public string? SourceUrl { get; set; } + + + // Relationship + public AppUser AppUser { get; set; } = null!; + public int AppUserId { get; set; } +} diff --git a/API/Entities/CollectionTag.cs b/API/Entities/CollectionTag.cs index a357b55d3..5370176de 100644 --- a/API/Entities/CollectionTag.cs +++ b/API/Entities/CollectionTag.cs @@ -9,6 +9,7 @@ namespace API.Entities; /// /// Represents a user entered field that is used as a tagging and grouping mechanism /// +[Obsolete("Use AppUserCollection instead")] [Index(nameof(Id), nameof(Promoted), IsUnique = true)] public class CollectionTag { diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index ac9cd15b9..a2a7f7722 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -14,6 +14,7 @@ public class SeriesMetadata : IHasConcurrencyToken public string Summary { get; set; } = string.Empty; + [Obsolete("Use AppUserCollection instead")] public ICollection CollectionTags { get; set; } = new List(); public ICollection Genres { get; set; } = new List(); diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 4f6a23115..d42d47507 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using API.Entities.Enums; using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Extensions; namespace API.Entities; @@ -105,6 +106,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate public ICollection Ratings { get; set; } = null!; public ICollection Progress { get; set; } = null!; + public ICollection Collections { get; set; } = null!; /// /// Relations to other Series, like Sequels, Prequels, etc @@ -114,6 +116,8 @@ public class Series : IEntityDate, IHasReadTimeEstimate public ICollection RelationOf { get; set; } = null!; + + // Relationships public List Volumes { get; set; } = null!; public Library Library { get; set; } = null!; @@ -131,4 +135,12 @@ public class Series : IEntityDate, IHasReadTimeEstimate LastChapterAdded = DateTime.Now; LastChapterAddedUtc = DateTime.UtcNow; } + + public bool MatchesSeriesByName(string nameNormalized, string localizedNameNormalized) + { + return NormalizedName == nameNormalized || + NormalizedLocalizedName == nameNormalized || + NormalizedName == localizedNameNormalized || + NormalizedLocalizedName == localizedNameNormalized; + } } diff --git a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs new file mode 100644 index 000000000..d30bafbfe --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; +using API.Data.Misc; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Metadata; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions.Filtering; + +public static class SearchQueryableExtensions +{ + public static IQueryable Search(this IQueryable queryable, + string searchQuery, int userId, AgeRestriction userRating) + { + return queryable + .Where(uc => uc.Promoted || uc.AppUserId == userId) + .Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%") + || EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%")) + .RestrictAgainstAgeRestriction(userRating) + .OrderBy(s => s.NormalizedTitle); + } + + public static IQueryable Search(this IQueryable queryable, + string searchQuery, int userId, AgeRestriction userRating) + { + return queryable + .Where(rl => rl.AppUserId == userId || rl.Promoted) + .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) + .RestrictAgainstAgeRestriction(userRating) + .OrderBy(s => s.NormalizedTitle); + } + + public static IQueryable Search(this IQueryable queryable, + string searchQuery, int userId, IEnumerable libraryIds) + { + return queryable + .Where(l => libraryIds.Contains(l.Id)) + .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) + .IsRestricted(QueryContext.Search) + .AsSplitQuery() + .OrderBy(l => l.Name.ToLower()); + } + + public static IQueryable SearchPeople(this IQueryable queryable, + string searchQuery, IEnumerable seriesIds) + { + return queryable + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%"))) + .AsSplitQuery() + .Distinct() + .OrderBy(p => p.NormalizedName); + } + + public static IQueryable SearchGenres(this IQueryable queryable, + string searchQuery, IEnumerable seriesIds) + { + return queryable + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) + .Distinct() + .OrderBy(t => t.NormalizedTitle); + } + + public static IQueryable SearchTags(this IQueryable queryable, + string searchQuery, IEnumerable seriesIds) + { + return queryable + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) + .AsSplitQuery() + .Distinct() + .OrderBy(t => t.NormalizedTitle); + } +} diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index 4a04d29a8..ce1a9700b 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -551,25 +551,26 @@ public static class SeriesFilter } public static IQueryable HasCollectionTags(this IQueryable queryable, bool condition, - FilterComparison comparison, IList collectionTags) + FilterComparison comparison, IList collectionTags, IList collectionSeries) { if (!condition || collectionTags.Count == 0) return queryable; + switch (comparison) { case FilterComparison.Equal: case FilterComparison.Contains: - return queryable.Where(s => s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id))); + return queryable.Where(s => collectionSeries.Contains(s.Id)); case FilterComparison.NotContains: case FilterComparison.NotEqual: - return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id))); + return queryable.Where(s => !collectionSeries.Contains(s.Id)); case FilterComparison.MustContains: - // Deconstruct and do a Union of a bunch of where statements since this doesn't translate + // // Deconstruct and do a Union of a bunch of where statements since this doesn't translate var queries = new List>() { queryable }; - queries.AddRange(collectionTags.Select(gId => queryable.Where(s => s.Metadata.CollectionTags.Any(p => p.Id == gId)))); + queries.AddRange(collectionSeries.Select(gId => queryable.Where(s => collectionSeries.Any(p => p == s.Id)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.GreaterThan: diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 52f4f254a..8cb0aed01 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -19,6 +19,23 @@ public static class IncludesExtensions queryable = queryable.Include(c => c.SeriesMetadatas); } + if (includes.HasFlag(CollectionTagIncludes.SeriesMetadataWithSeries)) + { + queryable = queryable.Include(c => c.SeriesMetadatas).ThenInclude(s => s.Series); + } + + return queryable.AsSplitQuery(); + } + + public static IQueryable Includes(this IQueryable queryable, + CollectionIncludes includes) + { + if (includes.HasFlag(CollectionIncludes.Series)) + { + queryable = queryable.Include(c => c.Items); + } + + return queryable.AsSplitQuery(); } @@ -206,6 +223,12 @@ public static class IncludesExtensions query = query.Include(u => u.ExternalSources); } + if (includeFlags.HasFlag(AppUserIncludes.Collections)) + { + query = query.Include(u => u.Collections) + .ThenInclude(c => c.Items); + } + return query.AsSplitQuery(); } diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index 8101c9d35..1d42723cc 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using API.Data.Misc; using API.Entities; using API.Entities.Enums; @@ -24,6 +25,7 @@ public static class RestrictByAgeExtensions return q; } + [Obsolete] public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -38,6 +40,20 @@ public static class RestrictByAgeExtensions sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + + if (restriction.IncludeUnknowns) + { + return queryable.Where(c => c.Items.All(sm => + sm.Metadata.AgeRating <= restriction.AgeRating)); + } + + return queryable.Where(c => c.Items.All(sm => + sm.Metadata.AgeRating <= restriction.AgeRating && sm.Metadata.AgeRating > AgeRating.Unknown)); + } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index f2f679a84..99edd0f17 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -3,6 +3,7 @@ using System.Linq; using API.Data.Migrations; using API.DTOs; using API.DTOs.Account; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Dashboard; using API.DTOs.Device; @@ -53,6 +54,8 @@ public class AutoMapperProfiles : Profile CreateMap(); CreateMap(); CreateMap(); + CreateMap() + .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)); CreateMap(); CreateMap(); CreateMap(); @@ -141,10 +144,6 @@ public class AutoMapperProfiles : Profile opt => opt.MapFrom( src => src.Genres.OrderBy(p => p.NormalizedTitle))) - .ForMember(dest => dest.CollectionTags, - opt => - opt.MapFrom( - src => src.CollectionTags.OrderBy(p => p.NormalizedTitle))) .ForMember(dest => dest.Tags, opt => opt.MapFrom( diff --git a/API/Helpers/Builders/AppUserCollectionBuilder.cs b/API/Helpers/Builders/AppUserCollectionBuilder.cs new file mode 100644 index 000000000..0fcdc0058 --- /dev/null +++ b/API/Helpers/Builders/AppUserCollectionBuilder.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Services.Plus; + +namespace API.Helpers.Builders; + +public class AppUserCollectionBuilder : IEntityBuilder +{ + private readonly AppUserCollection _collection; + public AppUserCollection Build() => _collection; + + public AppUserCollectionBuilder(string title, bool promoted = false) + { + title = title.Trim(); + _collection = new AppUserCollection() + { + Id = 0, + NormalizedTitle = title.ToNormalized(), + Title = title, + Promoted = promoted, + Summary = string.Empty, + AgeRating = AgeRating.Unknown, + Source = ScrobbleProvider.Kavita, + Items = new List() + }; + } + + public AppUserCollectionBuilder WithSource(ScrobbleProvider provider) + { + _collection.Source = provider; + return this; + } + + + public AppUserCollectionBuilder WithSummary(string summary) + { + _collection.Summary = summary; + return this; + } + + public AppUserCollectionBuilder WithIsPromoted(bool promoted) + { + _collection.Promoted = promoted; + return this; + } + + public AppUserCollectionBuilder WithItem(Series series) + { + _collection.Items ??= new List(); + _collection.Items.Add(series); + return this; + } + + public AppUserCollectionBuilder WithItems(IEnumerable series) + { + _collection.Items ??= new List(); + foreach (var s in series) + { + _collection.Items.Add(s); + } + + return this; + } + + public AppUserCollectionBuilder WithCoverImage(string cover) + { + _collection.CoverImage = cover; + return this; + } +} diff --git a/API/Helpers/Builders/CollectionTagBuilder.cs b/API/Helpers/Builders/CollectionTagBuilder.cs deleted file mode 100644 index e46720d79..000000000 --- a/API/Helpers/Builders/CollectionTagBuilder.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using API.Entities; -using API.Entities.Metadata; -using API.Extensions; - -namespace API.Helpers.Builders; - -public class CollectionTagBuilder : IEntityBuilder -{ - private readonly CollectionTag _collectionTag; - public CollectionTag Build() => _collectionTag; - - public CollectionTagBuilder(string title, bool promoted = false) - { - title = title.Trim(); - _collectionTag = new CollectionTag() - { - Id = 0, - NormalizedTitle = title.ToNormalized(), - Title = title, - Promoted = promoted, - Summary = string.Empty, - SeriesMetadatas = new List() - }; - } - - public CollectionTagBuilder WithId(int id) - { - _collectionTag.Id = id; - return this; - } - - public CollectionTagBuilder WithSummary(string summary) - { - _collectionTag.Summary = summary; - return this; - } - - public CollectionTagBuilder WithIsPromoted(bool promoted) - { - _collectionTag.Promoted = promoted; - return this; - } - - public CollectionTagBuilder WithSeriesMetadata(SeriesMetadata seriesMetadata) - { - _collectionTag.SeriesMetadatas ??= new List(); - _collectionTag.SeriesMetadatas.Add(seriesMetadata); - return this; - } - - public CollectionTagBuilder WithCoverImage(string cover) - { - _collectionTag.CoverImage = cover; - return this; - } -} diff --git a/API/Services/CollectionTagService.cs b/API/Services/CollectionTagService.cs index b024d687a..645cffcfa 100644 --- a/API/Services/CollectionTagService.cs +++ b/API/Services/CollectionTagService.cs @@ -1,13 +1,12 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; -using API.Data.Repositories; -using API.DTOs.CollectionTags; +using API.DTOs.Collection; using API.Entities; -using API.Entities.Metadata; -using API.Helpers.Builders; +using API.Extensions; +using API.Services.Plus; using API.SignalR; using Kavita.Common; @@ -16,15 +15,9 @@ namespace API.Services; public interface ICollectionTagService { - Task TagExistsByName(string name); - Task DeleteTag(CollectionTag tag); - Task UpdateTag(CollectionTagDto dto); - Task AddTagToSeries(CollectionTag? tag, IEnumerable seriesIds); - Task RemoveTagFromSeries(CollectionTag? tag, IEnumerable seriesIds); - Task GetTagOrCreate(int tagId, string title); - void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata); - CollectionTag CreateTag(string title); - Task RemoveTagsWithoutSeries(); + Task DeleteTag(int tagId, AppUser user); + Task UpdateTag(AppUserCollectionDto dto, int userId); + Task RemoveTagFromSeries(AppUserCollection? tag, IEnumerable seriesIds); } @@ -39,37 +32,44 @@ public class CollectionTagService : ICollectionTagService _eventHub = eventHub; } - /// - /// Checks if a collection exists with the name - /// - /// If empty or null, will return true as that is invalid - /// - public async Task TagExistsByName(string name) + public async Task DeleteTag(int tagId, AppUser user) { - if (string.IsNullOrEmpty(name.Trim())) return true; - return await _unitOfWork.CollectionTagRepository.TagExists(name); - } + var collectionTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(tagId); + if (collectionTag == null) return true; + + user.Collections.Remove(collectionTag); + + if (!_unitOfWork.HasChanges()) return true; - public async Task DeleteTag(CollectionTag tag) - { - _unitOfWork.CollectionTagRepository.Remove(tag); return await _unitOfWork.CommitAsync(); } - public async Task UpdateTag(CollectionTagDto dto) + + public async Task UpdateTag(AppUserCollectionDto dto, int userId) { - var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id); + var existingTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(dto.Id); if (existingTag == null) throw new KavitaException("collection-doesnt-exist"); + if (existingTag.AppUserId != userId) throw new KavitaException("access-denied"); var title = dto.Title.Trim(); if (string.IsNullOrEmpty(title)) throw new KavitaException("collection-tag-title-required"); - if (!title.Equals(existingTag.Title) && await TagExistsByName(dto.Title)) + + // Ensure the title doesn't exist on the user's account already + if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, userId)) throw new KavitaException("collection-tag-duplicate"); - existingTag.SeriesMetadatas ??= new List(); - existingTag.Title = title; - existingTag.NormalizedTitle = Tasks.Scanner.Parser.Parser.Normalize(dto.Title); - existingTag.Promoted = dto.Promoted; + existingTag.Items ??= new List(); + if (existingTag.Source == ScrobbleProvider.Kavita) + { + existingTag.Title = title; + existingTag.NormalizedTitle = dto.Title.ToNormalized(); + } + + var roles = await _unitOfWork.UserRepository.GetRoles(userId); + if (roles.Contains(PolicyConstants.AdminRole) || roles.Contains(PolicyConstants.PromoteRole)) + { + existingTag.Promoted = dto.Promoted; + } existingTag.CoverImageLocked = dto.CoverImageLocked; _unitOfWork.CollectionTagRepository.Update(existingTag); @@ -96,89 +96,31 @@ public class CollectionTagService : ICollectionTagService } /// - /// Adds a set of Series to a Collection + /// Removes series from Collection tag. Will recalculate max age rating. /// - /// A full Tag + /// /// /// - public async Task AddTagToSeries(CollectionTag? tag, IEnumerable seriesIds) + public async Task RemoveTagFromSeries(AppUserCollection? tag, IEnumerable seriesIds) { if (tag == null) return false; - var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(seriesIds); - foreach (var metadata in metadatas) - { - AddTagToSeriesMetadata(tag, metadata); - } - if (!_unitOfWork.HasChanges()) return true; - return await _unitOfWork.CommitAsync(); - } + tag.Items ??= new List(); + tag.Items = tag.Items.Where(s => !seriesIds.Contains(s.Id)).ToList(); - /// - /// Adds a collection tag to a SeriesMetadata - /// - /// Does not commit - /// - /// - /// - public void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata) - { - if (tag == null) return; - metadata.CollectionTags ??= new List(); - if (metadata.CollectionTags.Any(t => t.NormalizedTitle.Equals(tag.NormalizedTitle, StringComparison.InvariantCulture))) return; - - metadata.CollectionTags.Add(tag); - if (metadata.Id != 0) - { - _unitOfWork.SeriesMetadataRepository.Update(metadata); - } - } - - public async Task RemoveTagFromSeries(CollectionTag? tag, IEnumerable seriesIds) - { - if (tag == null) return false; - tag.SeriesMetadatas ??= new List(); - foreach (var seriesIdToRemove in seriesIds) - { - tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove)); - } - - - if (tag.SeriesMetadatas.Count == 0) + if (tag.Items.Count == 0) { _unitOfWork.CollectionTagRepository.Remove(tag); } if (!_unitOfWork.HasChanges()) return true; - return await _unitOfWork.CommitAsync(); - } + var result = await _unitOfWork.CommitAsync(); + if (tag.Items.Count > 0) + { + await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(tag); + } - /// - /// Tries to fetch the full tag, else returns a new tag. Adds to tracking but does not commit - /// - /// - /// - /// - public async Task GetTagOrCreate(int tagId, string title) - { - return await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata) ?? CreateTag(title); - } - - /// - /// This just creates the entity and adds to tracking. Use for checks of duplication. - /// - /// - /// - public CollectionTag CreateTag(string title) - { - var tag = new CollectionTagBuilder(title).Build(); - _unitOfWork.CollectionTagRepository.Add(tag); - return tag; - } - - public async Task RemoveTagsWithoutSeries() - { - return await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries() > 0; + return result; } } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index d253e8fa8..dd2108d98 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -278,7 +278,7 @@ public class MetadataService : IMetadataService await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated(); await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index c19ef8878..151b4cdd6 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -115,12 +115,6 @@ public class SeriesService : ISeriesService if (series == null) return false; series.Metadata ??= new SeriesMetadataBuilder() - .WithCollectionTags(updateSeriesMetadataDto.CollectionTags.Select(dto => - new CollectionTagBuilder(dto.Title) - .WithId(dto.Id) - .WithSummary(dto.Summary) - .WithIsPromoted(dto.Promoted) - .Build()).ToList()) .Build(); if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating) @@ -163,28 +157,16 @@ public class SeriesService : ISeriesService series.Metadata.WebLinks = string.Empty; } else { - series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks - .Split(",") + series.Metadata.WebLinks = string.Join(',', updateSeriesMetadataDto.SeriesMetadata?.WebLinks + .Split(',') .Where(s => !string.IsNullOrEmpty(s)) .Select(s => s.Trim())! ); } - if (updateSeriesMetadataDto.CollectionTags.Count > 0) - { - var allCollectionTags = (await _unitOfWork.CollectionTagRepository - .GetAllTagsByNamesAsync(updateSeriesMetadataDto.CollectionTags.Select(t => Parser.Normalize(t.Title)))).ToList(); - series.Metadata.CollectionTags ??= new List(); - UpdateCollectionsList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, tag => - { - series.Metadata.CollectionTags.Add(tag); - }); - } - - if (updateSeriesMetadataDto.SeriesMetadata?.Genres != null && - updateSeriesMetadataDto.SeriesMetadata.Genres.Any()) + updateSeriesMetadataDto.SeriesMetadata.Genres.Count != 0) { var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(updateSeriesMetadataDto.SeriesMetadata.Genres.Select(t => Parser.Normalize(t.Title)))).ToList(); series.Metadata.Genres ??= new List(); @@ -320,12 +302,6 @@ public class SeriesService : ISeriesService _logger.LogError(ex, "There was an issue cleaning up DB entries. This may happen if Komf is spamming updates. Nightly cleanup will work"); } - if (updateSeriesMetadataDto.CollectionTags == null) return true; - foreach (var tag in updateSeriesMetadataDto.CollectionTags) - { - await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection, - MessageFactory.SeriesAddedToCollectionEvent(tag.Id, seriesId), false); - } return true; } catch (Exception ex) @@ -337,46 +313,6 @@ public class SeriesService : ISeriesService return false; } - - private static void UpdateCollectionsList(ICollection? tags, Series series, IReadOnlyCollection allTags, - Action handleAdd) - { - // TODO: Move UpdateCollectionsList to a helper so we can easily test - if (tags == null) return; - // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different - var existingTags = series.Metadata.CollectionTags.ToList(); - foreach (var existing in existingTags) - { - if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) - { - // Remove tag - series.Metadata.CollectionTags.Remove(existing); - } - } - - // At this point, all tags that aren't in dto have been removed. - foreach (var tag in tags) - { - var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title); - if (existingTag != null) - { - if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title)) - { - handleAdd(existingTag); - } - } - else - { - // Add new tag - handleAdd(new CollectionTagBuilder(tag.Title) - .WithId(tag.Id) - .WithSummary(tag.Summary) - .WithIsPromoted(tag.Promoted) - .Build()); - } - } - } - /// /// /// @@ -461,7 +397,7 @@ public class SeriesService : ISeriesService } await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); _taskScheduler.CleanupChapters(allChapterIds.ToArray()); return true; } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 6271df312..4faf59e6c 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -107,7 +107,7 @@ public class CleanupService : ICleanupService await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); await _unitOfWork.ReadingListRepository.RemoveReadingListsWithoutSeries(); } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 48f97f219..8f1a85dcf 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -467,14 +467,13 @@ public class ParseScannedFiles } - chapters = infos - .OrderByNatural(info => info.Chapters) - .ToList(); - - // If everything is a special but we don't have any SpecialIndex, then order naturally and use 0, 1, 2 if (specialTreatment) { + chapters = infos + .OrderByNatural(info => Parser.Parser.RemoveExtensionIfSupported(info.Filename)!) + .ToList(); + foreach (var chapter in chapters) { chapter.IssueOrder = counter; @@ -483,6 +482,9 @@ public class ParseScannedFiles return; } + chapters = infos + .OrderByNatural(info => info.Chapters) + .ToList(); counter = 0f; var prevIssue = string.Empty; diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index e52ebeb49..5a5b8037c 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Metadata; +using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Extensions; @@ -371,12 +372,26 @@ public class ProcessSeries : IProcessSeries if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections) { + // Get the default admin to associate these tags to + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections); + if (defaultAdmin == null) return; + _logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name); foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) { - var t = await _tagManagerService.GetCollectionTag(collection); - if (t == null) continue; - _collectionTagService.AddTagToSeriesMetadata(t, series.Metadata); + var t = await _tagManagerService.GetCollectionTag(collection, defaultAdmin); + if (t.Item1 == null) continue; + + var tag = t.Item1; + + // Check if the Series is already on the tag + if (tag.Items.Any(s => s.MatchesSeriesByName(series.NormalizedName, series.NormalizedLocalizedName))) + { + continue; + } + + tag.Items.Add(series); + await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(tag); } } diff --git a/API/Services/Tasks/Scanner/TagManagerService.cs b/API/Services/Tasks/Scanner/TagManagerService.cs index e0b38b1aa..722804f90 100644 --- a/API/Services/Tasks/Scanner/TagManagerService.cs +++ b/API/Services/Tasks/Scanner/TagManagerService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -28,7 +29,7 @@ public interface ITagManagerService Task GetGenre(string genre); Task GetTag(string tag); Task GetPerson(string name, PersonRole role); - Task GetCollectionTag(string name); + Task> GetCollectionTag(string? tag, AppUser userWithCollections); } /// @@ -41,7 +42,7 @@ public class TagManagerService : ITagManagerService private Dictionary _genres; private Dictionary _tags; private Dictionary _people; - private Dictionary _collectionTags; + private Dictionary _collectionTags; private readonly SemaphoreSlim _genreSemaphore = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _tagSemaphore = new SemaphoreSlim(1, 1); @@ -57,10 +58,10 @@ public class TagManagerService : ITagManagerService public void Reset() { - _genres = new Dictionary(); - _tags = new Dictionary(); - _people = new Dictionary(); - _collectionTags = new Dictionary(); + _genres = []; + _tags = []; + _people = []; + _collectionTags = []; } public async Task Prime() @@ -71,7 +72,8 @@ public class TagManagerService : ITagManagerService .GroupBy(GetPersonKey) .Select(g => g.First()) .ToDictionary(GetPersonKey); - _collectionTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync(CollectionTagIncludes.SeriesMetadata)) + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser()!; + _collectionTags = (await _unitOfWork.CollectionTagRepository.GetCollectionsForUserAsync(defaultAdmin.Id, CollectionIncludes.Series)) .ToDictionary(t => t.NormalizedTitle); } @@ -183,28 +185,30 @@ public class TagManagerService : ITagManagerService /// /// /// - public async Task GetCollectionTag(string tag) + public async Task> GetCollectionTag(string? tag, AppUser userWithCollections) { - if (string.IsNullOrEmpty(tag)) return null; + if (string.IsNullOrEmpty(tag)) return Tuple.Create(null, false); await _collectionTagSemaphore.WaitAsync(); + AppUserCollection? result; try { - if (_collectionTags.TryGetValue(tag.ToNormalized(), out var result)) + if (_collectionTags.TryGetValue(tag.ToNormalized(), out result)) { - return result; + return Tuple.Create(result, false); } // We need to create a new Genre - result = new CollectionTagBuilder(tag).Build(); - _unitOfWork.CollectionTagRepository.Add(result); + result = new AppUserCollectionBuilder(tag).Build(); + userWithCollections.Collections.Add(result); + _unitOfWork.UserRepository.Update(userWithCollections); await _unitOfWork.CommitAsync(); _collectionTags.Add(result.NormalizedTitle, result); - return result; } finally { _collectionTagSemaphore.Release(); } + return Tuple.Create(result, true); } } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index ce45cdb28..48602a12e 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -134,7 +134,7 @@ public class StatsService : IStatsService HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(), - NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(), + NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count(), NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(), OPDSEnabled = serverSettings.EnableOpds, NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(), diff --git a/API/Startup.cs b/API/Startup.cs index be3fa29e6..3fc336e15 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -261,6 +261,7 @@ public class Startup await MigrateChapterFields.Migrate(dataContext, unitOfWork, logger); await MigrateChapterRange.Migrate(dataContext, unitOfWork, logger); await MigrateMangaFilePath.Migrate(dataContext, logger); + await MigrateCollectionTagToUserCollections.Migrate(dataContext, unitOfWork, logger); // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); diff --git a/UI/Web/src/app/_models/collection-tag.ts b/UI/Web/src/app/_models/collection-tag.ts index af0952f83..d3daa2d67 100644 --- a/UI/Web/src/app/_models/collection-tag.ts +++ b/UI/Web/src/app/_models/collection-tag.ts @@ -1,11 +1,33 @@ -export interface CollectionTag { - id: number; - title: string; - promoted: boolean; - /** - * This is used as a placeholder to store the coverImage url. The backend does not use this or send it. - */ - coverImage: string; - coverImageLocked: boolean; - summary: string; -} \ No newline at end of file +import {ScrobbleProvider} from "../_services/scrobbling.service"; +import {AgeRating} from "./metadata/age-rating"; + +// Deprecated in v0.8, replaced with UserCollection +// export interface CollectionTag { +// id: number; +// title: string; +// promoted: boolean; +// /** +// * This is used as a placeholder to store the coverImage url. The backend does not use this or send it. +// */ +// coverImage: string; +// coverImageLocked: boolean; +// summary: string; +// } + +export interface UserCollection { + id: number; + title: string; + promoted: boolean; + /** + * This is used as a placeholder to store the coverImage url. The backend does not use this or send it. + */ + coverImage: string; + coverImageLocked: boolean; + summary: string; + lastSyncUtc: string; + owner: string; + source: ScrobbleProvider; + sourceUrl: string | null; + ageRating: AgeRating; + +} diff --git a/UI/Web/src/app/_models/metadata/series-metadata.ts b/UI/Web/src/app/_models/metadata/series-metadata.ts index 10b6cfada..7e104c390 100644 --- a/UI/Web/src/app/_models/metadata/series-metadata.ts +++ b/UI/Web/src/app/_models/metadata/series-metadata.ts @@ -1,4 +1,3 @@ -import { CollectionTag } from "../collection-tag"; import { Genre } from "./genre"; import { AgeRating } from "./age-rating"; import { PublicationStatus } from "./publication-status"; @@ -12,7 +11,6 @@ export interface SeriesMetadata { totalCount: number; maxCount: number; - collectionTags: Array; genres: Array; tags: Array; writers: Array; diff --git a/UI/Web/src/app/_pipes/manga-format-icon.pipe.ts b/UI/Web/src/app/_pipes/manga-format-icon.pipe.ts index 73580252a..ba63e7df6 100644 --- a/UI/Web/src/app/_pipes/manga-format-icon.pipe.ts +++ b/UI/Web/src/app/_pipes/manga-format-icon.pipe.ts @@ -13,15 +13,15 @@ export class MangaFormatIconPipe implements PipeTransform { transform(format: MangaFormat): string { switch (format) { case MangaFormat.EPUB: - return 'fa-book'; + return 'fa fa-book'; case MangaFormat.ARCHIVE: - return 'fa-file-archive'; + return 'fa-solid fa-file-zipper'; case MangaFormat.IMAGE: - return 'fa-image'; + return 'fa-solid fa-file-image'; case MangaFormat.PDF: - return 'fa-file-pdf'; + return 'fa-solid fa-file-pdf'; case MangaFormat.UNKNOWN: - return 'fa-question'; + return 'fa-solid fa-file-circle-question'; } } diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 34551b7a3..1a1484d3f 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -21,7 +21,9 @@ export enum Role { Bookmark = 'Bookmark', Download = 'Download', ChangeRestriction = 'Change Restriction', - ReadOnly = 'Read Only' + ReadOnly = 'Read Only', + Login = 'Login', + Promote = 'Promote', } @Injectable({ @@ -96,6 +98,10 @@ export class AccountService { return user && user.roles.includes(Role.ReadOnly); } + hasPromoteRole(user: User) { + return user && user.roles.includes(Role.Promote) || user.roles.includes(Role.Admin); + } + getRoles() { return this.httpClient.get(this.baseUrl + 'account/roles'); } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 645fd250c..1e211d7f5 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { map, Observable, shareReplay } from 'rxjs'; import { Chapter } from '../_models/chapter'; -import { CollectionTag } from '../_models/collection-tag'; +import {UserCollection} from '../_models/collection-tag'; import { Device } from '../_models/device/device'; import { Library } from '../_models/library/library'; import { ReadingList } from '../_models/reading-list'; @@ -10,6 +10,7 @@ import { Volume } from '../_models/volume'; import { AccountService } from './account.service'; import { DeviceService } from './device.service'; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; +import {User} from "../_models/user"; export enum Action { Submenu = -1, @@ -97,12 +98,23 @@ export enum Action { RemoveRuleGroup = 21, MarkAsVisible = 22, MarkAsInvisible = 23, + /** + * Promotes the underlying item (Reading List, Collection) + */ + Promote = 24, + UnPromote = 25 } +/** + * Callback for an action + */ +export type ActionCallback = (action: ActionItem, data: T) => void; +export type ActionAllowedCallback = (action: ActionItem) => boolean; + export interface ActionItem { title: string; action: Action; - callback: (action: ActionItem, data: T) => void; + callback: ActionCallback; requiresAdmin: boolean; children: Array>; /** @@ -132,7 +144,7 @@ export class ActionFactoryService { chapterActions: Array> = []; - collectionTagActions: Array> = []; + collectionTagActions: Array> = []; readingListActions: Array> = []; @@ -141,13 +153,11 @@ export class ActionFactoryService { sideNavStreamActions: Array> = []; isAdmin = false; - hasDownloadRole = false; constructor(private accountService: AccountService, private deviceService: DeviceService) { this.accountService.currentUser$.subscribe((user) => { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); - this.hasDownloadRole = this.accountService.hasDownloadRole(user); } else { this._resetActions(); return; // If user is logged out, we don't need to do anything @@ -157,39 +167,39 @@ export class ActionFactoryService { }); } - getLibraryActions(callback: (action: ActionItem, library: Library) => void) { + getLibraryActions(callback: ActionCallback) { return this.applyCallbackToList(this.libraryActions, callback); } - getSeriesActions(callback: (action: ActionItem, series: Series) => void) { + getSeriesActions(callback: ActionCallback) { return this.applyCallbackToList(this.seriesActions, callback); } - getSideNavStreamActions(callback: (action: ActionItem, series: SideNavStream) => void) { + getSideNavStreamActions(callback: ActionCallback) { return this.applyCallbackToList(this.sideNavStreamActions, callback); } - getVolumeActions(callback: (action: ActionItem, volume: Volume) => void) { + getVolumeActions(callback: ActionCallback) { return this.applyCallbackToList(this.volumeActions, callback); } - getChapterActions(callback: (action: ActionItem, chapter: Chapter) => void) { + getChapterActions(callback: ActionCallback) { return this.applyCallbackToList(this.chapterActions, callback); } - getCollectionTagActions(callback: (action: ActionItem, collectionTag: CollectionTag) => void) { - return this.applyCallbackToList(this.collectionTagActions, callback); + getCollectionTagActions(callback: ActionCallback) { + return this.applyCallbackToList(this.collectionTagActions, callback); } - getReadingListActions(callback: (action: ActionItem, readingList: ReadingList) => void) { + getReadingListActions(callback: ActionCallback) { return this.applyCallbackToList(this.readingListActions, callback); } - getBookmarkActions(callback: (action: ActionItem, series: Series) => void) { + getBookmarkActions(callback: ActionCallback) { return this.applyCallbackToList(this.bookmarkActions, callback); } - getMetadataFilterActions(callback: (action: ActionItem, data: any) => void) { + getMetadataFilterActions(callback: ActionCallback) { const actions = [ {title: 'add-rule-group-and', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, {title: 'add-rule-group-or', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, @@ -260,7 +270,7 @@ export class ActionFactoryService { action: Action.Edit, title: 'edit', callback: this.dummyCallback, - requiresAdmin: true, + requiresAdmin: false, children: [], }, { @@ -271,6 +281,20 @@ export class ActionFactoryService { class: 'danger', children: [], }, + { + action: Action.Promote, + title: 'promote', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + { + action: Action.UnPromote, + title: 'unpromote', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, ]; this.seriesActions = [ @@ -326,7 +350,7 @@ export class ActionFactoryService { action: Action.AddToCollection, title: 'add-to-collection', callback: this.dummyCallback, - requiresAdmin: true, + requiresAdmin: false, children: [], }, ], diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 6e0e6448e..5a14c54ac 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -20,6 +20,8 @@ import { MemberService } from './member.service'; import { ReaderService } from './reader.service'; import { SeriesService } from './series.service'; import {translate, TranslocoService} from "@ngneat/transloco"; +import {UserCollection} from "../_models/collection-tag"; +import {CollectionTagService} from "./collection-tag.service"; export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -43,7 +45,8 @@ export class ActionService implements OnDestroy { constructor(private libraryService: LibraryService, private seriesService: SeriesService, private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, - private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService) { } + private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService, + private readonly collectionTagService: CollectionTagService) { } ngOnDestroy() { this.onDestroy.next(); @@ -380,6 +383,43 @@ export class ActionService implements OnDestroy { }); } + /** + * Mark all series as Unread. + * @param collections UserCollection, should have id, pagesRead populated + * @param promoted boolean, promoted state + * @param callback Optional callback to perform actions after API completes + */ + promoteMultipleCollections(collections: Array, promoted: boolean, callback?: BooleanActionCallback) { + this.collectionTagService.promoteMultipleCollections(collections.map(v => v.id), promoted).pipe(take(1)).subscribe(() => { + if (promoted) { + this.toastr.success(translate('toasts.collections-promoted')); + } else { + this.toastr.success(translate('toasts.collections-unpromoted')); + } + + if (callback) { + callback(true); + } + }); + } + + /** + * Deletes multiple collections + * @param collections UserCollection, should have id, pagesRead populated + * @param callback Optional callback to perform actions after API completes + */ + async deleteMultipleCollections(collections: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-collections'))) return; + + this.collectionTagService.deleteMultipleCollections(collections.map(v => v.id)).pipe(take(1)).subscribe(() => { + this.toastr.success(translate('toasts.collections-deleted')); + + if (callback) { + callback(true); + } + }); + } + addMultipleToReadingList(seriesId: number, volumes: Array, chapters?: Array, callback?: BooleanActionCallback) { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); diff --git a/UI/Web/src/app/_services/collection-tag.service.ts b/UI/Web/src/app/_services/collection-tag.service.ts index 7d1991fc5..876bd4cbb 100644 --- a/UI/Web/src/app/_services/collection-tag.service.ts +++ b/UI/Web/src/app/_services/collection-tag.service.ts @@ -1,11 +1,12 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { CollectionTag } from '../_models/collection-tag'; -import { TextResonse } from '../_types/text-response'; -import { ImageService } from './image.service'; +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {environment} from 'src/environments/environment'; +import {UserCollection} from '../_models/collection-tag'; +import {TextResonse} from '../_types/text-response'; import {MalStack} from "../_models/collection/mal-stack"; +import {Action, ActionItem} from "./action-factory.service"; +import {User} from "../_models/user"; +import {AccountService} from "./account.service"; @Injectable({ providedIn: 'root' @@ -14,24 +15,25 @@ export class CollectionTagService { baseUrl = environment.apiUrl; - constructor(private httpClient: HttpClient, private imageService: ImageService) { } + constructor(private httpClient: HttpClient, private accountService: AccountService) { } - allTags() { - return this.httpClient.get(this.baseUrl + 'collection/'); + allCollections(ownedOnly = false) { + return this.httpClient.get(this.baseUrl + 'collection?ownedOnly=' + ownedOnly); } - search(query: string) { - return this.httpClient.get(this.baseUrl + 'collection/search?queryString=' + encodeURIComponent(query)).pipe(map(tags => { - tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id))); - return tags; - })); + allCollectionsForSeries(seriesId: number, ownedOnly = false) { + return this.httpClient.get(this.baseUrl + 'collection/all-series?ownedOnly=' + ownedOnly + '&seriesId=' + seriesId); } - updateTag(tag: CollectionTag) { + updateTag(tag: UserCollection) { return this.httpClient.post(this.baseUrl + 'collection/update', tag, TextResonse); } - updateSeriesForTag(tag: CollectionTag, seriesIdsToRemove: Array) { + promoteMultipleCollections(tags: Array, promoted: boolean) { + return this.httpClient.post(this.baseUrl + 'collection/promote-multiple', {collectionIds: tags, promoted}, TextResonse); + } + + updateSeriesForTag(tag: UserCollection, seriesIdsToRemove: Array) { return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, TextResonse); } @@ -47,7 +49,19 @@ export class CollectionTagService { return this.httpClient.delete(this.baseUrl + 'collection?tagId=' + tagId, TextResonse); } + deleteMultipleCollections(tags: Array) { + return this.httpClient.post(this.baseUrl + 'collection/delete-multiple', {collectionIds: tags}, TextResonse); + } + getMalStacks() { return this.httpClient.get>(this.baseUrl + 'collection/mal-stacks'); } + + actionListFilter(action: ActionItem, user: User) { + const canPromote = this.accountService.hasAdminRole(user) || this.accountService.hasPromoteRole(user); + const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote; + + if (isPromotionAction) return canPromote; + return true; + } } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 7395931a8..927305134 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -6,7 +6,7 @@ import { environment } from 'src/environments/environment'; import { UtilityService } from '../shared/_services/utility.service'; import { Chapter } from '../_models/chapter'; import { ChapterMetadata } from '../_models/metadata/chapter-metadata'; -import { CollectionTag } from '../_models/collection-tag'; +import { UserCollection } from '../_models/collection-tag'; import { PaginatedResult } from '../_models/pagination'; import { Series } from '../_models/series'; import { RelatedSeries } from '../_models/series-detail/related-series'; @@ -162,16 +162,12 @@ export class SeriesService { } getMetadata(seriesId: number) { - return this.httpClient.get(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => { - items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id)); - return items; - })); + return this.httpClient.get(this.baseUrl + 'series/metadata?seriesId=' + seriesId); } - updateMetadata(seriesMetadata: SeriesMetadata, collectionTags: CollectionTag[]) { + updateMetadata(seriesMetadata: SeriesMetadata) { const data = { seriesMetadata, - collectionTags, }; return this.httpClient.post(this.baseUrl + 'series/metadata', data, TextResonse); } diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts index e9e9952dc..9dd1773d7 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts @@ -1,11 +1,20 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, DestroyRef, + EventEmitter, + inject, + Input, + OnInit, + Output +} from '@angular/core'; import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap'; -import { take } from 'rxjs'; import { AccountService } from 'src/app/_services/account.service'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import {CommonModule} from "@angular/common"; import {TranslocoDirective} from "@ngneat/transloco"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Component({ selector: 'app-card-actionables', @@ -17,6 +26,10 @@ import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; }) export class CardActionablesComponent implements OnInit { + private readonly cdRef = inject(ChangeDetectorRef); + private readonly accountService = inject(AccountService); + private readonly destroyRef = inject(DestroyRef); + @Input() iconClass = 'fa-ellipsis-v'; @Input() btnClass = ''; @Input() actions: ActionItem[] = []; @@ -27,20 +40,22 @@ export class CardActionablesComponent implements OnInit { isAdmin: boolean = false; canDownload: boolean = false; + canPromote: boolean = false; submenu: {[key: string]: NgbDropdown} = {}; - constructor(private readonly cdRef: ChangeDetectorRef, private accountService: AccountService) { } ngOnInit(): void { - this.accountService.currentUser$.pipe(take(1)).subscribe((user) => { + this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { if (!user) return; this.isAdmin = this.accountService.hasAdminRole(user); this.canDownload = this.accountService.hasDownloadRole(user); + this.canPromote = this.accountService.hasPromoteRole(user); // We want to avoid an empty menu when user doesn't have access to anything if (!this.isAdmin && this.actions.filter(a => !a.requiresAdmin).length === 0) { this.actions = []; } + this.cdRef.markForCheck(); }); } @@ -61,7 +76,10 @@ export class CardActionablesComponent implements OnInit { willRenderAction(action: ActionItem) { return (action.requiresAdmin && this.isAdmin) || (action.action === Action.Download && (this.canDownload || this.isAdmin)) - || (!action.requiresAdmin && action.action !== Action.Download); + || (!action.requiresAdmin && action.action !== Action.Download) + || (action.action === Action.Promote && (this.canPromote || this.isAdmin)) + || (action.action === Action.UnPromote && (this.canPromote || this.isAdmin)) + ; } shouldRenderSubMenu(action: ActionItem, dynamicList: null | Array) { diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html index 560de7039..4778f47f2 100644 --- a/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component.html @@ -6,23 +6,37 @@
+ } + +
    + @for(collectionTag of lists | filter: filterList; let i = $index; track collectionTag.title) { +
  • + {{collectionTag.title}} + @if (collectionTag.promoted) { + + } +
  • + } + + @if (lists.length === 0 && !loading) { +
  • {{t('no-data')}}
  • + } + + @if (loading) { +
  • +
    + {{t('loading')}} +
    +
  • + }