diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index 6471cec91..3be2c6043 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -12,10 +12,8 @@ using AutoMapper; using Hangfire; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; using NSubstitute; -using Polly; using Xunit.Abstractions; namespace API.Tests; diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index 96d74b46d..aca7eb801 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -3,6 +3,7 @@ using System.Linq; using API.Data.Misc; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Entities.Person; using API.Extensions.QueryExtensions; using API.Helpers.Builders; @@ -15,9 +16,10 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] [InlineData(false, 1)] - public void RestrictAgainstAgeRestriction_Series_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + public void RestrictAgainstAgeRestriction_Series_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, + int expectedCount) { - var items = new List() + var items = new List { new SeriesBuilder("Test 1") .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) @@ -30,7 +32,7 @@ public class QueryableExtensionsTests .Build() }; - var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction { AgeRating = AgeRating.Teen, IncludeUnknowns = includeUnknowns @@ -41,23 +43,28 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] [InlineData(false, 1)] - public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, + int expectedCount) { - var items = new List() + var items = new List { new AppUserCollectionBuilder("Test") - .WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build()) + .WithItem(new SeriesBuilder("S1") + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build()) .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()) + .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 AppUserCollectionBuilder("Test 3") - .WithItem(new SeriesBuilder("S3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()).Build()) - .Build(), + .WithItem(new SeriesBuilder("S3") + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()).Build()) + .Build() }; - var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction { AgeRating = AgeRating.Teen, IncludeUnknowns = includeUnknowns @@ -68,9 +75,10 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] [InlineData(false, 2)] - public void RestrictAgainstAgeRestriction_Genre_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + public void RestrictAgainstAgeRestriction_Genre_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, + int expectedCount) { - var items = new List() + var items = new List { new GenreBuilder("A") .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) @@ -81,10 +89,10 @@ public class QueryableExtensionsTests .Build(), new GenreBuilder("C") .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()) - .Build(), + .Build() }; - var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction { AgeRating = AgeRating.Teen, IncludeUnknowns = includeUnknowns @@ -95,9 +103,10 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] [InlineData(false, 2)] - public void RestrictAgainstAgeRestriction_Tag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + public void RestrictAgainstAgeRestriction_Tag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, + int expectedCount) { - var items = new List() + var items = new List { new TagBuilder("Test 1") .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) @@ -108,10 +117,10 @@ public class QueryableExtensionsTests .Build(), new TagBuilder("Test 3") .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()) - .Build(), + .Build() }; - var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction { AgeRating = AgeRating.Teen, IncludeUnknowns = includeUnknowns @@ -122,13 +131,15 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] [InlineData(false, 2)] - public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedPeopleCount) + public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, + int expectedPeopleCount) { // Arrange var items = new List { CreatePersonWithSeriesMetadata("Test1", AgeRating.Teen), - CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, AgeRating.Teen), // 2 series on this person, restrict will still allow access + CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, + AgeRating.Teen), // 2 series on this person, restrict will still allow access CreatePersonWithSeriesMetadata("Test3", AgeRating.X18Plus) }; @@ -166,21 +177,502 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] [InlineData(false, 1)] - public void RestrictAgainstAgeRestriction_ReadingList_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + public void RestrictAgainstAgeRestriction_ReadingList_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, + int expectedCount) { - - var items = new List() + var items = new List { new ReadingListBuilder("Test List").WithRating(AgeRating.Teen).Build(), new ReadingListBuilder("Test List").WithRating(AgeRating.Unknown).Build(), - new ReadingListBuilder("Test List").WithRating(AgeRating.X18Plus).Build(), + new ReadingListBuilder("Test List").WithRating(AgeRating.X18Plus).Build() }; - var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction { AgeRating = AgeRating.Teen, IncludeUnknowns = includeUnknowns }); Assert.Equal(expectedCount, filtered.Count()); } + + [Fact] + public void RestrictBySocialPreferences_SocialLibs() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(2, [1], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(3, [], AgeRating.NotApplicable, true, false, true) + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.Unknown), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Unknown), + CreateAnnotationInLibraryWithAgeRating(2, 2, AgeRating.Unknown), + CreateAnnotationInLibraryWithAgeRating(3, 1, AgeRating.Unknown), + CreateAnnotationInLibraryWithAgeRating(3, 1, AgeRating.Unknown) + ]; + + // Own annotation, and the other in lib 1 + Assert.Equal(2, annotations.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + + // Own annotations, and from user 1 in lib 1 + Assert.Equal(3, annotations.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + + // Own annotations, and user 1 in lib 1 and user 2 in lib 1 + Assert.Equal(4, annotations.AsQueryable().RestrictBySocialPreferences(3, userPreferences).Count()); + } + + [Theory] + [InlineData(true, 4, 3)] + [InlineData(false, 3, 2)] + public void RestrictBySocialPreferences_AgeRating(bool includeUnknowns, int expected1, int expected2) + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(2, [], AgeRating.Mature, includeUnknowns, true, true) + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.AdultsOnly), + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.Unknown), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Unknown) + ]; + + Assert.Equal(expected1, annotations.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + Assert.Equal(expected2, annotations.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_UserNotSharingAnnotations() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, false, true), // User 1 NOT sharing + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true) + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Everyone) + ]; + + // User 2 should only see their own annotation since User 1 is not sharing + Assert.Equal(1, annotations.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + + // User 1 should see both (own + user 2's shared) + Assert.Equal(2, annotations.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_UserNotViewingOtherAnnotations() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, false), // User 1 NOT viewing others + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true) + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Everyone) + ]; + + // User 1 should only see their own annotation (not viewing others) + Assert.Equal(1, annotations.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + + // User 2 should see both + Assert.Equal(2, annotations.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_RequestingUserLibraryFilter() + { + IList userPreferences = + [ + CreateUserPreferences(1, [1], AgeRating.NotApplicable, true, true, true), // User 1 only wants lib 1 + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true) + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(1, 2, AgeRating.Everyone), // User 1's own in lib 2 + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(2, 2, AgeRating.Everyone) + ]; + + // User 1 should see: own (always) + user 2's in lib 1 only = 3 + Assert.Equal(3, annotations.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_RequestingUserAgeRatingFilter() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.Teen, false, true, true), // User 1 wants Teen max, no unknowns + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true) + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.AdultsOnly), // User 1's own - always included + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.AdultsOnly), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Teen), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Unknown) + ]; + + // User 1 should see: own (1) + user 2's Teen (1) = 2 + Assert.Equal(2, annotations.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_CombinedLibraryAndAgeRatingFilters() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(2, [1], AgeRating.Teen, true, true, + true) // User 2: lib 1 only + Teen max + unknowns ok + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(1, 2, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.AdultsOnly), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Teen), + CreateAnnotationInLibraryWithAgeRating(2, 2, AgeRating.Everyone) // User 2's own in lib 2 + ]; + + // User 2 should see: + // - Own annotations (always): 2 + // - User 1's in lib 1 with age <= Teen: 1 (Everyone) + // Total: 3 + Assert.Equal(3, annotations.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_MultipleUsersWithDifferentLibraryRestrictions() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(2, [1], AgeRating.NotApplicable, true, true, true), // User 2 shares lib 1 only + CreateUserPreferences(3, [2], AgeRating.NotApplicable, true, true, true) // User 3 shares lib 2 only + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(2, 2, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(3, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(3, 2, AgeRating.Everyone) + ]; + + // User 1 should see: own (1) + user 2 lib 1 (1) + user 3 lib 2 (1) = 3 + Assert.Equal(3, annotations.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_NoOtherUsersSharing() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, false, true), + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, false, true) + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Everyone) + ]; + + // Each user should only see their own + Assert.Equal(1, annotations.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + Assert.Equal(1, annotations.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + } + + [Theory] + [InlineData(AgeRating.Everyone, true, 3)] + [InlineData(AgeRating.Everyone, false, 2)] + [InlineData(AgeRating.Teen, true, 4)] + [InlineData(AgeRating.Mature17Plus, false, 3)] + public void RestrictBySocialPreferences_RequestingUserAgeRatingVariations(AgeRating maxRating, bool includeUnknowns, + int expected) + { + IList userPreferences = + [ + CreateUserPreferences(1, [], maxRating, includeUnknowns, true, true), + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true) + ]; + + IList annotations = + [ + CreateAnnotationInLibraryWithAgeRating(1, 1, AgeRating.AdultsOnly), // Own - always included + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Everyone), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Teen), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Mature), + CreateAnnotationInLibraryWithAgeRating(2, 1, AgeRating.Unknown) + ]; + + Assert.Equal(expected, annotations.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + private static AppUserPreferences CreateUserPreferences(int user, IList libs, AgeRating ageRating, + bool includeUnknowns, bool share, bool seeAn) + { + return new AppUserPreferences + { + AppUserId = user, + Theme = null, + SocialPreferences = new AppUserSocialPreferences + { + ShareReviews = share, + ShareAnnotations = share, + ViewOtherAnnotations = seeAn, + SocialLibraries = libs, + SocialMaxAgeRating = ageRating, + SocialIncludeUnknowns = includeUnknowns + } + }; + } + + private static AppUserAnnotation CreateAnnotationInLibraryWithAgeRating(int user, int lib, AgeRating ageRating) + { + return new AppUserAnnotation + { + XPath = null, + LibraryId = lib, + SeriesId = 0, + VolumeId = 0, + ChapterId = 0, + AppUserId = user, + Series = new Series + { + Name = null, + NormalizedName = null, + NormalizedLocalizedName = null, + SortName = null, + LocalizedName = null, + OriginalName = null, + Metadata = new SeriesMetadata + { + AgeRating = ageRating + } + } + }; + } + + [Fact] + public void RestrictBySocialPreferences_Rating_SocialLibs() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(2, [1], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(3, [], AgeRating.NotApplicable, true, false, true) + ]; + + IList ratings = + [ + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.Unknown), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Unknown), + CreateRatingInLibraryWithAgeRating(2, 2, AgeRating.Unknown), + CreateRatingInLibraryWithAgeRating(3, 1, AgeRating.Unknown), + CreateRatingInLibraryWithAgeRating(3, 1, AgeRating.Unknown) + ]; + + // Own rating, and the other in lib 1 + Assert.Equal(2, ratings.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + + // Own ratings, and from user 1 in lib 1 + Assert.Equal(3, ratings.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + + // Own ratings, and user 1 in lib 1 and user 2 in lib 1 + Assert.Equal(4, ratings.AsQueryable().RestrictBySocialPreferences(3, userPreferences).Count()); + } + + [Theory] + [InlineData(true, 4, 3)] + [InlineData(false, 3, 2)] + public void RestrictBySocialPreferences_Rating_AgeRating(bool includeUnknowns, int expected1, int expected2) + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(2, [], AgeRating.Mature, includeUnknowns, true, true) + ]; + + IList ratings = + [ + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.AdultsOnly), + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.Unknown), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Unknown) + ]; + + var f = ratings.AsQueryable().RestrictBySocialPreferences(1, userPreferences); + Assert.Equal(expected1, ratings.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + Assert.Equal(expected2, ratings.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_Rating_UserNotSharingReviews() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, false, true), + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true) + ]; + + IList ratings = + [ + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Everyone) + ]; + + Assert.Equal(1, ratings.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + Assert.Equal(2, ratings.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_Rating_RequestingUserLibraryFilter() + { + IList userPreferences = + [ + CreateUserPreferences(1, [1], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true) + ]; + + IList ratings = + [ + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(1, 2, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(2, 2, AgeRating.Everyone) + ]; + + Assert.Equal(3, ratings.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_Rating_RequestingUserAgeRatingFilter() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.Teen, false, true, true), + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true) + ]; + + IList ratings = + [ + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.AdultsOnly), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.AdultsOnly), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Teen), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Unknown) + ]; + + Assert.Equal(2, ratings.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_Rating_CombinedFilters() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(2, [1], AgeRating.Teen, true, true, true) + ]; + + IList ratings = + [ + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(1, 2, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.AdultsOnly), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Teen), + CreateRatingInLibraryWithAgeRating(2, 2, AgeRating.Everyone) + ]; + + Assert.Equal(3, ratings.AsQueryable().RestrictBySocialPreferences(2, userPreferences).Count()); + } + + [Fact] + public void RestrictBySocialPreferences_Rating_MultipleUsersWithDifferentLibraryRestrictions() + { + IList userPreferences = + [ + CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(2, [1], AgeRating.NotApplicable, true, true, true), + CreateUserPreferences(3, [2], AgeRating.NotApplicable, true, true, true) + ]; + + IList ratings = + [ + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(2, 2, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(3, 1, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(3, 2, AgeRating.Everyone) + ]; + + Assert.Equal(3, ratings.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + [Theory] + [InlineData(AgeRating.Everyone, true, 3)] + [InlineData(AgeRating.Everyone, false, 2)] + [InlineData(AgeRating.Teen, true, 4)] + [InlineData(AgeRating.Mature17Plus, false, 3)] + public void RestrictBySocialPreferences_Rating_RequestingUserAgeRatingVariations(AgeRating maxRating, + bool includeUnknowns, int expected) + { + IList userPreferences = + [ + CreateUserPreferences(1, [], maxRating, includeUnknowns, true, true), + CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true) + ]; + + IList ratings = + [ + CreateRatingInLibraryWithAgeRating(1, 1, AgeRating.AdultsOnly), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Everyone), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Teen), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Mature), + CreateRatingInLibraryWithAgeRating(2, 1, AgeRating.Unknown) + ]; + + Assert.Equal(expected, ratings.AsQueryable().RestrictBySocialPreferences(1, userPreferences).Count()); + } + + private static AppUserRating CreateRatingInLibraryWithAgeRating(int user, int lib, AgeRating ageRating) + { + return new AppUserRating + { + AppUserId = user, + Series = new Series + { + Name = null, + NormalizedName = null, + NormalizedLocalizedName = null, + SortName = null, + LocalizedName = null, + OriginalName = null, + LibraryId = lib, + Metadata = new SeriesMetadata + { + AgeRating = ageRating + } + } + }; + } } diff --git a/API.Tests/Services/AnnotationServiceTests.cs b/API.Tests/Services/AnnotationServiceTests.cs new file mode 100644 index 000000000..85d664586 --- /dev/null +++ b/API.Tests/Services/AnnotationServiceTests.cs @@ -0,0 +1,234 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Reader; +using API.DTOs.Settings; +using API.Entities; +using API.Helpers.Builders; +using API.Services; +using API.SignalR; +using AutoMapper; +using Kavita.Common; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Services; + +public class AnnotationServiceTests(ITestOutputHelper outputHelper): AbstractDbTest(outputHelper) +{ + + [Fact] + public async Task CreateAnnotationTest() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (user, annotationService, bookService, chapter, _) = await Setup(unitOfWork, context, mapper); + + // No highlight or Selected Text + await Assert.ThrowsAsync(async () => + await annotationService.CreateAnnotation(user.Id, new AnnotationDto + { + XPath = null, + ChapterId = 0, + VolumeId = 0, + SeriesId = 0, + LibraryId = 0, + OwnerUserId = 0 + })); + + + // Chapter title + const int pageNum = 1; + const string chapterTitle = "My Chapter Title"; + bookService.GenerateTableOfContents(null!).ReturnsForAnyArgs([ + new BookChapterItem + { + Page = pageNum, + Title = chapterTitle, + } + ]); + + var dto = await CreateSimpleAnnotation(annotationService, user, chapter); + Assert.Equal(chapterTitle, dto.ChapterTitle); + } + + [Fact] + public async Task UpdateAnnotationTestFailures() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (user, annotationService, _, chapter, eventHub) = await Setup(unitOfWork, context, mapper); + + // Can't update without id + await Assert.ThrowsAsync(async () => + await annotationService.UpdateAnnotation(user.Id, new AnnotationDto + { + XPath = null, + ChapterId = 0, + VolumeId = 0, + SeriesId = 0, + LibraryId = 0, + OwnerUserId = 0 + })); + + var dto = await CreateSimpleAnnotation(annotationService, user, chapter); + + // Can't update others annotations + var otherUser = new AppUserBuilder("other", "other@localhost").Build(); + await Assert.ThrowsAsync(async () => await annotationService.UpdateAnnotation(otherUser.Id, dto)); + } + + [Fact] + public async Task UpdateAnnotationTestChanges() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (user, annotationService, _, chapter, eventHub) = await Setup(unitOfWork, context, mapper); + + var dto = await CreateSimpleAnnotation(annotationService, user, chapter); + + // Update relevant fields + dto.ContainsSpoiler = true; + dto.SelectedSlotIndex = 2; + dto.Comment = "{}"; + dto.CommentHtml = "

Something New

"; + dto.CommentPlainText = "Something unrelated"; // Should not be used + dto = await annotationService.UpdateAnnotation(user.Id, dto); + + Assert.True(dto.ContainsSpoiler); + Assert.Equal(2, dto.SelectedSlotIndex); + Assert.Equal("

Something New

", dto.CommentHtml); + Assert.Equal("Something New", dto.CommentPlainText); + + // Ensure event was sent out to UI + await eventHub.Received().SendMessageToAsync( + MessageFactory.AnnotationUpdate, + Arg.Any(), + user.Id); + } + + [Fact] + public async Task ExportAnnotationsCorrectExportUser() + { + var unitOfWork = Substitute.For(); + var annotationRepo = Substitute.For(); + var settingsRepo = Substitute.For(); + unitOfWork.AnnotationRepository.Returns(annotationRepo); + unitOfWork.SettingsRepository.Returns(settingsRepo); + + settingsRepo.GetSettingsDtoAsync().Returns(new ServerSettingDto + { + HostName = "", + }); + + var annotationService = new AnnotationService( + Substitute.For>(), + unitOfWork, + Substitute.For(), + Substitute.For()); + + await annotationService.ExportAnnotations(1); + + await annotationRepo.Received().GetFullAnnotationsByUserIdAsync(1); + await annotationRepo.DidNotReceive().GetFullAnnotations(1, []); + } + + [Fact] + public async Task ExportAnnotationsCorrectExportSpecific() + { + var unitOfWork = Substitute.For(); + var annotationRepo = Substitute.For(); + var settingsRepo = Substitute.For(); + unitOfWork.AnnotationRepository.Returns(annotationRepo); + unitOfWork.SettingsRepository.Returns(settingsRepo); + + settingsRepo.GetSettingsDtoAsync().Returns(new ServerSettingDto + { + HostName = "", + }); + + var annotationService = new AnnotationService( + Substitute.For>(), + unitOfWork, + Substitute.For(), + Substitute.For()); + + List ids = [1, 2, 3]; // Received checks pointers I think + await annotationService.ExportAnnotations(1, ids); + + await annotationRepo.DidNotReceive().GetFullAnnotationsByUserIdAsync(1); + await annotationRepo.Received().GetFullAnnotations(1, ids); + } + + private static async Task CreateSimpleAnnotation(IAnnotationService annotationService, AppUser user, Chapter chapter) + { + return await annotationService.CreateAnnotation(user.Id, new AnnotationDto + { + XPath = "", + ChapterId = chapter.Id, + VolumeId = chapter.VolumeId, + SeriesId = chapter.Volume.SeriesId, + LibraryId = chapter.Volume.Series.LibraryId, + PageNumber = 1, + OwnerUserId = user.Id, + HighlightCount = 1, + SelectedText = "Something" + }); + } + + private static async Task<(AppUser, IAnnotationService, IBookService, Chapter, IEventHub)> Setup( + IUnitOfWork unitOfWork, + DataContext context, + IMapper mapper) + { + var user = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost") + .WithRole(PolicyConstants.AdminRole) + .Build(); + + context.AppUser.Add(user); + await unitOfWork.CommitAsync(); + + user = await unitOfWork.UserRepository.GetUserByIdAsync(user.Id, AppUserIncludes.DashboardStreams); + Assert.NotNull(user); + + await new AccountService( + null!, + Substitute.For>(), + unitOfWork, + mapper, + Substitute.For() + ).SeedUser(user); + + var chapter = new ChapterBuilder("1") + .Build(); + + var lib = new LibraryBuilder("Manga") + .WithAppUser(user) + .WithSeries(new SeriesBuilder("Spice and Wolf") + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build()) + .Build(); + + context.Library.Add(lib); + await unitOfWork.CommitAsync(); + + chapter.Volume = lib.Series.First().Volumes.First(); + chapter.Volume.Series = lib.Series.First(); + chapter.Volume.Series.Library = lib; + + + var bookService = Substitute.For(); + var eventHub = Substitute.For(); + var annotationService = new AnnotationService( + Substitute.For>(), + unitOfWork, bookService, eventHub); + + return (user, annotationService, bookService, chapter, eventHub); + } + +} diff --git a/API.Tests/Services/OpdsServiceTests.cs b/API.Tests/Services/OpdsServiceTests.cs new file mode 100644 index 000000000..8d908caeb --- /dev/null +++ b/API.Tests/Services/OpdsServiceTests.cs @@ -0,0 +1,423 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.OPDS.Requests; +using API.DTOs.Progress; +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 AutoMapper; +using Hangfire; +using Hangfire.InMemory; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Services; + +public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTest(testOutputHelper) +{ + private readonly string _testFilePath = Path.Join(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/OpdsService"), "test.zip"); + + #region Setup + + private Tuple SetupService(IUnitOfWork unitOfWork, IMapper mapper) + { + JobStorage.Current = new InMemoryStorage(); + + var ds = new DirectoryService(Substitute.For>(), new FileSystem()); + + var readerService = new ReaderService(unitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), ds, Substitute.For()); + + var localizationService = + new LocalizationService(ds, new MockHostingEnvironment(), Substitute.For(), unitOfWork); + + var seriesService = new SeriesService(unitOfWork, Substitute.For(), Substitute.For(), + Substitute.For>(), Substitute.For(), + localizationService, Substitute.For()); + + var opdsService = new OpdsService(unitOfWork, localizationService, + seriesService, Substitute.For(), + ds, readerService, mapper); + + return new Tuple(opdsService, readerService); + } + + private async Task SetupSeriesAndUser(DataContext context, IUnitOfWork unitOfWork, int numberOfSeries = 1) + { + var library = new LibraryBuilder("Test Lib", LibraryType.Manga).Build(); + + unitOfWork.LibraryRepository.Add(library); + await unitOfWork.CommitAsync(); + + + context.AppUser.Add(new AppUserBuilder("majora2007", "majora2007") + .WithLibrary(library) + .WithLocale("en") + .Build()); + + await context.SaveChangesAsync(); + + Assert.NotEmpty(await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(1)); + + var counter = 0; + foreach (var i in Enumerable.Range(0, numberOfSeries)) + { + var series = new SeriesBuilder("Test " + (i + 1)) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1") + .WithSortOrder(counter) + .WithPages(10) + .WithFile(new MangaFileBuilder(_testFilePath, MangaFormat.Archive, 10).Build()) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithFile(new MangaFileBuilder(_testFilePath, MangaFormat.Archive, 10).Build()) + .WithSortOrder(counter + 1) + .WithPages(10) + .Build()) + .Build()) + .Build(); + series.Library = library; + + context.Series.Add(series); + counter += 2; + } + + await unitOfWork.CommitAsync(); + + + + var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + Assert.NotNull(user); + + // // Build a reading list + // + // var readingList = new ReadingListBuilder("Test RL").WithAppUserId(user.Id).WithItem(new ReadingListItem + // { + // SeriesId = 1, + // VolumeId = 1, + // ChapterId = 0, + // Order = 0, + // Series = null, + // Volume = null, + // Chapter = null + // }) + + + return user; + } + + #endregion + + #region Continue Points + + [Fact] + public async Task ContinuePoint_ShouldWorkWithProgress() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + + + var user = await SetupSeriesAndUser(context, unitOfWork); + + var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); + Assert.NotNull(firstChapter); + + // Mark Chapter 1 as read + await readerService.MarkChaptersAsRead(user, 1, [firstChapter]); + Assert.True(unitOfWork.HasChanges()); + await unitOfWork.CommitAsync(); + + // Generate Series Feed and validate first element is a Continue From Chapter 2 + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = 0 + }); + + Assert.NotEmpty(feed.Entries); + Assert.Equal(3, feed.Entries.Count); + Assert.StartsWith("Continue Reading from", feed.Entries.First().Title); + } + + + [Fact] + public async Task ContinuePoint_DoesntExist_WhenNoProgress() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + + + var user = await SetupSeriesAndUser(context, unitOfWork); + + var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); + Assert.NotNull(firstChapter); + + + // Generate Series Feed and validate first element is a Continue From Chapter 2 + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = 0 + }); + + Assert.NotEmpty(feed.Entries); + Assert.Equal(2, feed.Entries.Count); + } + #endregion + + #region Misc + + [Fact] + public async Task NoProgressEncoding() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + + + var user = await SetupSeriesAndUser(context, unitOfWork); + + var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); + Assert.NotNull(firstChapter); + + + // Generate Series Feed and validate first element is a Continue From Chapter 2 + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = 0 + }); + + Assert.NotEmpty(feed.Entries); + Assert.Contains(OpdsService.NoReadingProgressIcon, feed.Entries.First().Title); + } + + [Fact] + public async Task QuarterProgressEncoding() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + + + var user = await SetupSeriesAndUser(context, unitOfWork); + + var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); + Assert.NotNull(firstChapter); + + // Mark Chapter 1 as read + await readerService.SaveReadingProgress(new ProgressDto + { + VolumeId = firstChapter.VolumeId, + ChapterId = firstChapter.Id, + PageNum = 2, // 10 total pages + SeriesId = 1, + LibraryId = 1, + BookScrollId = null, + LastModifiedUtc = default + }, user.Id); + + // Generate Series Feed and validate first element is a Continue From Chapter 2 + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = 0 + }); + + Assert.NotEmpty(feed.Entries); + Assert.Contains(OpdsService.QuarterReadingProgressIcon, feed.Entries.First().Title); + } + + + [Fact] + public async Task HalfProgressEncoding() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + + + var user = await SetupSeriesAndUser(context, unitOfWork); + + var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); + Assert.NotNull(firstChapter); + + // Mark Chapter 1 as read + await readerService.SaveReadingProgress(new ProgressDto + { + VolumeId = firstChapter.VolumeId, + ChapterId = firstChapter.Id, + PageNum = 5, // 10 total pages + SeriesId = 1, + LibraryId = 1, + BookScrollId = null, + LastModifiedUtc = default + }, user.Id); + + // Generate Series Feed and validate first element is a Continue From Chapter 2 + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = 0 + }); + + Assert.NotEmpty(feed.Entries); + Assert.Contains(OpdsService.HalfReadingProgressIcon, feed.Entries.First().Title); + } + + [Fact] + public async Task AboveHalfProgressEncoding() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + + + var user = await SetupSeriesAndUser(context, unitOfWork); + + var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); + Assert.NotNull(firstChapter); + + // Mark Chapter 1 as read + await readerService.SaveReadingProgress(new ProgressDto + { + VolumeId = firstChapter.VolumeId, + ChapterId = firstChapter.Id, + PageNum = 7, // 10 total pages + SeriesId = 1, + LibraryId = 1, + BookScrollId = null, + LastModifiedUtc = default + }, user.Id); + + // Generate Series Feed and validate first element is a Continue From Chapter 2 + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = 0 + }); + + Assert.NotEmpty(feed.Entries); + Assert.Contains(OpdsService.AboveHalfReadingProgressIcon, feed.Entries.First().Title); + } + + [Fact] + public async Task FullProgressEncoding() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + + + var user = await SetupSeriesAndUser(context, unitOfWork); + + var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1); + Assert.NotNull(firstChapter); + + // Mark Chapter 1 as read + await readerService.SaveReadingProgress(new ProgressDto + { + VolumeId = firstChapter.VolumeId, + ChapterId = firstChapter.Id, + PageNum = 10, // 10 total pages + SeriesId = 1, + LibraryId = 1, + BookScrollId = null, + LastModifiedUtc = default + }, user.Id); + + // Generate Series Feed and validate first element is a Continue From Chapter 2 + var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = 1, + PageNumber = 0 + }); + + Assert.NotEmpty(feed.Entries); + Assert.Equal(3, feed.Entries.Count); + Assert.Contains(OpdsService.FullReadingProgressIcon, feed.Entries[1].Title); // The continue from will show the 2nd chapter + } + + #endregion + + #region Entity Feeds + + [Fact] + public async Task PaginationWorks() + { + var (unitOfWork, context, mapper) = await CreateDatabase(); + var (opdsService, readerService) = SetupService(unitOfWork, mapper); + var user = await SetupSeriesAndUser(context, unitOfWork, 100); + + var libs = await unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(1); + var feed = await opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest() + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = libs.First().Id, + PageNumber = OpdsService.FirstPageNumber + }); + + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + var feed2 = await opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest() + { + ApiKey = user.ApiKey, + Prefix = OpdsService.DefaultApiPrefix, + BaseUrl = string.Empty, + UserId = user.Id, + EntityId = libs.First().Id, + PageNumber = OpdsService.FirstPageNumber + }); + Assert.Equal(OpdsService.PageSize, feed.Entries.Count); + + // Ensure there is no overlap + Assert.NotSame(feed.Entries.Select(e => e.Id), feed2.Entries.Select(e => e.Id)); + + + + + } + #endregion + + #region Detail Feeds + #endregion + +} diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 5b65f295c..9c2086874 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -26,7 +26,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb { private readonly ITestOutputHelper _testOutputHelper = testOutputHelper; - private async Task Setup(IUnitOfWork unitOfWork) + private ReaderService Setup(IUnitOfWork unitOfWork) { return new ReaderService(unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), @@ -52,7 +52,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task CapPageToChapterTest() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -82,7 +82,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task SaveReadingProgress_ShouldCreateNewEntity() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -121,7 +121,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task SaveReadingProgress_ShouldUpdateExisting() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -178,7 +178,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkChaptersAsReadTest() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -219,7 +219,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkChapterAsUnreadTest() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -267,7 +267,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb { // V1 -> V2 var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -309,7 +309,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb { // V1 -> V2 var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1-2") @@ -343,7 +343,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb { // V1 -> V2 var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1.0") @@ -387,7 +387,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldRollIntoNextVolume() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -427,7 +427,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldRollIntoNextVolumeWithFloat() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -468,7 +468,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldRollIntoChaptersFromVolume() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -503,7 +503,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldRollIntoNextChapter_WhenVolumesAreOnlyOneChapter_AndNextChapterIs0() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -544,7 +544,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromSpecial() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -575,7 +575,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromVolume() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -601,7 +601,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -628,7 +628,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials_FirstIsVolume() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -657,7 +657,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_WithSpecials() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -696,7 +696,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial_NoLooseLeafChapters() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -738,7 +738,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldMoveFromLooseLeafChapterToSpecial() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -777,7 +777,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromSpecial_WithVolumeAndLooseLeafChapters() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -817,7 +817,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -858,7 +858,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetNextChapterIdAsync_ShouldRollIntoNextVolume_WhenAllVolumesHaveAChapterToo() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -898,7 +898,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb { // V1 -> V2 var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -940,7 +940,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb { // V1 -> V2 var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -979,7 +979,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldGetPrevVolume_2() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -1031,7 +1031,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldRollIntoPrevVolume() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -1073,7 +1073,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToVolume() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -1111,7 +1111,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolume() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -1141,7 +1141,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapter() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -1170,7 +1170,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapterAndHasNormalChapters() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -1201,7 +1201,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapterAndHasNormalChapters2() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -1247,7 +1247,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromChapter() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -1277,7 +1277,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToSpecial() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -1321,7 +1321,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldMoveFromChapterToVolume() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -1356,7 +1356,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetPrevChapterIdAsync_ShouldRollIntoPrevVolume_WhenAllVolumesHaveAChapterToo() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -1390,7 +1390,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstVolume_NoProgress() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").Build()) @@ -1434,7 +1434,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstVolume_WhenFirstVolumeIsAlsoTaggedAsChapter1_WithProgress() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder("1").WithPages(3).Build()) @@ -1474,7 +1474,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstVolume_WhenFirstVolumeIsAlsoTaggedAsChapter1Through11_WithProgress() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder("1", "1-11").WithPages(3).Build()) @@ -1514,7 +1514,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstNonSpecial() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) @@ -1580,7 +1580,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstNonSpecial2() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") // Loose chapters .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -1655,7 +1655,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenHasSpecial() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") // Loose chapters .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -1686,7 +1686,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstSpecial() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) @@ -1748,7 +1748,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenNonRead_LooseLeafChaptersAndVolumes() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("230").WithPages(1).Build()) @@ -1785,7 +1785,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnLooseChapter_WhenAllVolumesRead_HasSpecialAndLooseChapters_Unread() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("100").WithPages(1).Build()) @@ -1841,7 +1841,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnLooseChapter_WhenAllVolumesAndAFewLooseChaptersRead() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("100").WithPages(1).Build()) @@ -1900,7 +1900,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) @@ -1958,7 +1958,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllReadAndAllChapters() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -1999,7 +1999,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstSpecial_WhenAllReadAndAllChapters() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -2058,7 +2058,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -2111,7 +2111,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnUnreadSingleVolume_WhenThereAreSomeSingleVolumesBeforeLooseLeafChapters() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var readChapter1 = new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build(); var readChapter2 = new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build(); var volume = new VolumeBuilder("3").WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()).Build(); @@ -2178,7 +2178,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_ShouldReturnLastLooseChapter() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) @@ -2274,7 +2274,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task GetContinuePoint_DuplicateIssueNumberBetweenChapters() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) @@ -2357,7 +2357,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkChaptersUntilAsRead_ShouldMarkAllChaptersAsRead() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -2397,7 +2397,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkChaptersUntilAsRead_ShouldMarkUptTillChapterNumberAsRead() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -2439,7 +2439,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkChaptersUntilAsRead_ShouldMarkAsRead_OnlyVolumesWithChapter0() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") @@ -2475,7 +2475,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkChaptersUntilAsRead_ShouldMarkAsReadAnythingUntil() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -2544,7 +2544,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkSeriesAsReadTest() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") @@ -2585,7 +2585,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkSeriesAsUnreadTest() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -2659,7 +2659,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkVolumesUntilAsRead_ShouldMarkVolumesAsRead() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) @@ -2717,7 +2717,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb public async Task MarkVolumesUntilAsRead_ShouldMarkChapterBasedVolumesAsRead() { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("10").WithPages(1).Build()) @@ -2796,7 +2796,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb { var (unitOfWork, context, _) = await CreateDatabase(); - var readerService = await Setup(unitOfWork); + var readerService = Setup(unitOfWork); var files = wides.Select((b, i) => new FileDimensionDto() {PageNumber = i, Height = 1, Width = 1, FileName = string.Empty, IsWide = b}).ToList(); var pairs = readerService.GetPairs(files); diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 304d25d1b..0930a0690 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -1057,6 +1057,56 @@ public class ScannerServiceTests: AbstractDbTest Assert.Contains(postLib.Series, x => x.Name == "Futoku No Guild"); } + /// + /// Test that if we have 3 directories with + /// + [Fact] + public async Task ScanLibrary_MetadataDisabled_NoCombining() + { + var (unitOfWork, _, _) = await CreateDatabase(); + var scannerHelper = new ScannerHelper(unitOfWork, _testOutputHelper); + + const string testcase = "Series with slight differences No Metadata - Manga.json"; + + // Mark every series with metadata saying it belongs to Toradora + var infos = new Dictionary + { + {"Toradora v01.cbz", new ComicInfo() + { + Series = "Toradora", + }}, + {"Toradora! v01.cbz", new ComicInfo() + { + Series = "Toradora", + }}, + {"Toradora! Light Novel v01.cbz", new ComicInfo() + { + Series = "Toradora", + }}, + + }; + + var library = await scannerHelper.GenerateScannerData(testcase, infos); + + // Disable metadata + library.EnableMetadata = false; + unitOfWork.LibraryRepository.Update(library); + await unitOfWork.CommitAsync(); + + var scanner = scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + // Validate that there are 2 series + Assert.NotNull(postLib); + Assert.Equal(3, postLib.Series.Count); + + Assert.Contains(postLib.Series, x => x.Name == "Toradora"); + Assert.Contains(postLib.Series, x => x.Name == "Toradora!"); + Assert.Contains(postLib.Series, x => x.Name == "Toradora! Light Novel"); + } + [Fact] public async Task ScanLibrary_SortName_NoPrefix() { diff --git a/API.Tests/Services/Test Data/OpdsService/test.zip b/API.Tests/Services/Test Data/OpdsService/test.zip new file mode 100644 index 000000000..db38cd60f Binary files /dev/null and b/API.Tests/Services/Test Data/OpdsService/test.zip differ diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with slight differences No Metadata - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with slight differences No Metadata - Manga.json new file mode 100644 index 000000000..b8e275a05 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with slight differences No Metadata - Manga.json @@ -0,0 +1,8 @@ +[ + "Toradora/Toradora v01.cbz", + "Toradora/Toradora v02.cbz", + "Toradora!/Toradora! v01.cbz", + "Toradora!/Toradora! v02.cbz", + "Toradora! Light Novel/Toradora! Light Novel v01.cbz", + "Toradora! Light Novel/Toradora! Light Novel v02.cbz" +] diff --git a/API/Controllers/AnnotationController.cs b/API/Controllers/AnnotationController.cs index 6601087a0..187259572 100644 --- a/API/Controllers/AnnotationController.cs +++ b/API/Controllers/AnnotationController.cs @@ -21,23 +21,14 @@ using Microsoft.Extensions.Logging; namespace API.Controllers; -public class AnnotationController : BaseApiController +public class AnnotationController( + IUnitOfWork unitOfWork, + ILogger logger, + ILocalizationService localizationService, + IEventHub eventHub, + IAnnotationService annotationService) + : BaseApiController { - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private readonly ILocalizationService _localizationService; - private readonly IEventHub _eventHub; - private readonly IAnnotationService _annotationService; - - public AnnotationController(IUnitOfWork unitOfWork, ILogger logger, - ILocalizationService localizationService, IEventHub eventHub, IAnnotationService annotationService) - { - _unitOfWork = unitOfWork; - _logger = logger; - _localizationService = localizationService; - _eventHub = eventHub; - _annotationService = annotationService; - } /// /// Returns a list of annotations for browsing @@ -50,7 +41,7 @@ public class AnnotationController : BaseApiController { userParams ??= UserParams.Default; - var list = await _unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams); + var list = await unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams); Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); return Ok(list); @@ -64,7 +55,7 @@ public class AnnotationController : BaseApiController [HttpGet("all")] public async Task>> GetAnnotations(int chapterId) { - return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId)); + return Ok(await unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId)); } /// @@ -75,7 +66,7 @@ public class AnnotationController : BaseApiController [HttpGet("all-for-series")] public async Task> GetAnnotationsBySeries(int seriesId) { - return Ok(await _unitOfWork.UserRepository.GetAnnotationDtosBySeries(User.GetUserId(), seriesId)); + return Ok(await unitOfWork.UserRepository.GetAnnotationDtosBySeries(User.GetUserId(), seriesId)); } /// @@ -86,7 +77,7 @@ public class AnnotationController : BaseApiController [HttpGet("{annotationId}")] public async Task> GetAnnotation(int annotationId) { - return Ok(await _unitOfWork.UserRepository.GetAnnotationDtoById(User.GetUserId(), annotationId)); + return Ok(await unitOfWork.UserRepository.GetAnnotationDtoById(User.GetUserId(), annotationId)); } /// @@ -99,11 +90,11 @@ public class AnnotationController : BaseApiController { try { - return Ok(await _annotationService.CreateAnnotation(User.GetUserId(), dto)); + return Ok(await annotationService.CreateAnnotation(User.GetUserId(), dto)); } catch (KavitaException ex) { - return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + return BadRequest(await localizationService.Translate(User.GetUserId(), ex.Message)); } } @@ -117,14 +108,76 @@ public class AnnotationController : BaseApiController { try { - return Ok(await _annotationService.UpdateAnnotation(User.GetUserId(), dto)); + return Ok(await annotationService.UpdateAnnotation(User.GetUserId(), dto)); } catch (KavitaException ex) { - return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + return BadRequest(await localizationService.Translate(User.GetUserId(), ex.Message)); } } + /// + /// Adds a like for the currently authenticated user if not already from the annotations with given ids + /// + /// + /// + [HttpPost("like")] + public async Task LikeAnnotations(IList ids) + { + var userId = User.GetUserId(); + + var annotations = await unitOfWork.AnnotationRepository.GetAnnotations(userId, ids); + if (annotations.Count != ids.Count) + { + return BadRequest(); + } + + foreach (var annotation in annotations.Where(a => !a.Likes.Contains(userId) && a.AppUserId != userId)) + { + annotation.Likes.Add(userId); + unitOfWork.AnnotationRepository.Update(annotation); + } + + if (unitOfWork.HasChanges()) + { + await unitOfWork.CommitAsync(); + } + + + return Ok(); + } + + /// + /// Removes likes for the currently authenticated user if present from the annotations with given ids + /// + /// + /// + [HttpPost("unlike")] + public async Task UnLikeAnnotations(IList ids) + { + var userId = User.GetUserId(); + + var annotations = await unitOfWork.AnnotationRepository.GetAnnotations(userId, ids); + if (annotations.Count != ids.Count) + { + return BadRequest(); + } + + foreach (var annotation in annotations.Where(a => a.Likes.Contains(userId))) + { + annotation.Likes.Remove(userId); + unitOfWork.AnnotationRepository.Update(annotation); + } + + if (unitOfWork.HasChanges()) + { + await unitOfWork.CommitAsync(); + } + + + return Ok(); + } + /// /// Delete the annotation for the user /// @@ -133,11 +186,11 @@ public class AnnotationController : BaseApiController [HttpDelete] public async Task DeleteAnnotation(int annotationId) { - var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(annotationId); - if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "annotation-delete")); + var annotation = await unitOfWork.AnnotationRepository.GetAnnotation(annotationId); + if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest(await localizationService.Translate(User.GetUserId(), "annotation-delete")); - _unitOfWork.AnnotationRepository.Remove(annotation); - await _unitOfWork.CommitAsync(); + unitOfWork.AnnotationRepository.Remove(annotation); + await unitOfWork.CommitAsync(); return Ok(); } @@ -152,14 +205,14 @@ public class AnnotationController : BaseApiController { var userId = User.GetUserId(); - var annotations = await _unitOfWork.AnnotationRepository.GetAnnotations(annotationIds); + var annotations = await unitOfWork.AnnotationRepository.GetAnnotations(userId, annotationIds); if (annotations.Any(a => a.AppUserId != userId)) { return BadRequest(); } - _unitOfWork.AnnotationRepository.Remove(annotations); - await _unitOfWork.CommitAsync(); + unitOfWork.AnnotationRepository.Remove(annotations); + await unitOfWork.CommitAsync(); return Ok(); } @@ -173,10 +226,10 @@ public class AnnotationController : BaseApiController { userParams ??= UserParams.Default; - var list = await _unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams); + var list = await unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams); var annotations = list.Select(a => a.Id).ToList(); - var json = await _annotationService.ExportAnnotations(User.GetUserId(), annotations); + var json = await annotationService.ExportAnnotations(User.GetUserId(), annotations); if (string.IsNullOrEmpty(json)) return BadRequest(); var bytes = Encoding.UTF8.GetBytes(json); @@ -192,7 +245,7 @@ public class AnnotationController : BaseApiController [HttpPost("export")] public async Task ExportAnnotations(IList? annotations = null) { - var json = await _annotationService.ExportAnnotations(User.GetUserId(), annotations); + var json = await annotationService.ExportAnnotations(User.GetUserId(), annotations); if (string.IsNullOrEmpty(json)) return BadRequest(); var bytes = Encoding.UTF8.GetBytes(json); diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index c5a7b8fe4..057a9bf49 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -24,6 +24,7 @@ using Hangfire; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using TaskScheduler = API.Services.TaskScheduler; @@ -557,6 +558,15 @@ public class LibraryController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false); + + var userPreferences = await _unitOfWork.DataContext.AppUserPreferences.ToListAsync(); + foreach (var userPreference in userPreferences) + { + userPreference.SocialPreferences.SocialLibraries = userPreference.SocialPreferences.SocialLibraries + .Where(l => l != libraryId).ToList(); + } + + await _unitOfWork.CommitAsync(); return true; } catch (Exception ex) diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 267dbb7af..01f78a370 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -87,11 +87,11 @@ public class OpdsController : BaseApiController private async Task> GetPrefix() { var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value; - var prefix = "/api/opds/"; + var prefix = OpdsService.DefaultApiPrefix; if (!Configuration.DefaultBaseUrl.Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase)) { // We need to update the Prefix to account for baseUrl - prefix = baseUrl + "api/opds/"; + prefix = baseUrl + OpdsService.DefaultApiPrefix; } return new Tuple(baseUrl, prefix); @@ -103,7 +103,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/smart-filters/{filterId}")] [Produces("application/xml")] - public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 1) + public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { var userId = GetUserIdFromContext(); var (baseUrl, prefix) = await GetPrefix(); @@ -130,7 +130,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/smart-filters")] [Produces("application/xml")] - public async Task GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetSmartFilters(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -162,7 +162,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/libraries")] [Produces("application/xml")] - public async Task GetLibraries(string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetLibraries(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -193,7 +193,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/want-to-read")] [Produces("application/xml")] - public async Task GetWantToRead(string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetWantToRead(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -224,7 +224,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/collections")] [Produces("application/xml")] - public async Task GetCollections(string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetCollections(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -256,7 +256,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/collections/{collectionId}")] [Produces("application/xml")] - public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -288,7 +288,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/reading-list")] [Produces("application/xml")] - public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -320,7 +320,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/reading-list/{readingListId}")] [Produces("application/xml")] - public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -354,7 +354,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/libraries/{libraryId}")] [Produces("application/xml")] - public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -386,7 +386,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/recently-added")] [Produces("application/xml")] - public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -417,7 +417,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/more-in-genre")] [Produces("application/xml")] - public async Task GetMoreInGenre(string apiKey, [FromQuery] int genreId, [FromQuery] int pageNumber = 1) + public async Task GetMoreInGenre(string apiKey, [FromQuery] int genreId, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -448,7 +448,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/recently-updated")] [Produces("application/xml")] - public async Task GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { @@ -478,7 +478,7 @@ public class OpdsController : BaseApiController /// [HttpGet("{apiKey}/on-deck")] [Produces("application/xml")] - public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1) + public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber) { try { diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index e33ecebe1..4172983d9 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -665,4 +665,16 @@ public class SeriesController : BaseApiController return Ok(); } + /// + /// Returns all Series that a user has access to + /// + /// + [HttpGet("series-with-annotations")] + public async Task>> GetSeriesWithAnnotations() + { + var data = await _unitOfWork.AnnotationRepository.GetSeriesWithAnnotations(User.GetUserId()); + return Ok(data); + } + + } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index f67b90a43..ccdce18b7 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -42,6 +42,16 @@ public class UsersController : BaseApiController public async Task DeleteUser(string username) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username); + if (user == null) return BadRequest(); + + // Remove all likes for the user, so like counts are correct + var annotations = await _unitOfWork.AnnotationRepository.GetAllAnnotations(); + foreach (var annotation in annotations.Where(a => a.Likes.Contains(user.Id))) + { + annotation.Likes.Remove(user.Id); + _unitOfWork.AnnotationRepository.Update(annotation); + } + _unitOfWork.UserRepository.Delete(user); //(TODO: After updating a role or removing a user, delete their token) @@ -108,10 +118,16 @@ public class UsersController : BaseApiController existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; existingPreferences.NoTransitions = preferencesDto.NoTransitions; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; - existingPreferences.ShareReviews = preferencesDto.ShareReviews; existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled; existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots; + var allLibs = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)) + .Select(l => l.Id).ToList(); + + preferencesDto.SocialPreferences.SocialLibraries = preferencesDto.SocialPreferences.SocialLibraries + .Where(l => allLibs.Contains(l)).ToList(); + existingPreferences.SocialPreferences = preferencesDto.SocialPreferences; + if (await _licenseService.HasActiveLicense()) { existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled; diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index c00c15708..51f618a8e 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -87,4 +87,5 @@ public enum AnnotationFilterField /// This is the text the user wrote /// Comment = 6, + Series = 7 } diff --git a/API/DTOs/Reader/AnnotationDto.cs b/API/DTOs/Reader/AnnotationDto.cs index 729f3ccda..13dd6fe39 100644 --- a/API/DTOs/Reader/AnnotationDto.cs +++ b/API/DTOs/Reader/AnnotationDto.cs @@ -53,6 +53,12 @@ public sealed record AnnotationDto /// public int SelectedSlotIndex { get; set; } + /// + public ISet Likes { get; set; } + + public string SeriesName { get; set; } + public string LibraryName { get; set; } + public required int ChapterId { get; set; } public required int VolumeId { get; set; } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index f4c72bfe8..9a1b1a70b 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -31,9 +31,6 @@ public sealed record UserPreferencesDto /// [Required] public bool CollapseSeriesRelationships { get; set; } = false; - /// - [Required] - public bool ShareReviews { get; set; } = false; /// [Required] public string Locale { get; set; } @@ -48,4 +45,11 @@ public sealed record UserPreferencesDto /// [Required] public List BookReaderHighlightSlots { get; set; } + + #region Social + + /// + public AppUserSocialPreferences SocialPreferences { get; set; } = new(); + + #endregion } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index dacf18920..7f9113781 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -13,6 +13,7 @@ using API.Entities.Metadata; using API.Entities.MetadataMatching; using API.Entities.Person; using API.Entities.Scrobble; +using API.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -226,30 +227,18 @@ public sealed class DataContext : IdentityDbContext() .Property(x => x.AgeRatingMappings) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new Dictionary() - ); + .HasJsonConversion([]); // Ensure blacklist is stored as a JSON array builder.Entity() .Property(x => x.Blacklist) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() - ); + .HasJsonConversion([]); builder.Entity() .Property(x => x.Whitelist) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() - ); + .HasJsonConversion([]); builder.Entity() .Property(x => x.Overrides) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() - ); + .HasJsonConversion([]); // Configure one-to-many relationship builder.Entity() @@ -280,44 +269,45 @@ public sealed class DataContext : IdentityDbContext() .Property(rp => rp.LibraryIds) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasJsonConversion([]) .HasColumnType("TEXT"); builder.Entity() .Property(rp => rp.SeriesIds) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasJsonConversion([]) .HasColumnType("TEXT"); builder.Entity() .Property(sm => sm.KPlusOverrides) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? - new List()) + .HasJsonConversion([]) .HasColumnType("TEXT") .HasDefaultValue(new List()); builder.Entity() .Property(sm => sm.KPlusOverrides) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasJsonConversion([]) .HasColumnType("TEXT") .HasDefaultValue(new List()); builder.Entity() .Property(a => a.BookReaderHighlightSlots) - .HasConversion( - v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), - v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasJsonConversion([]) .HasColumnType("TEXT") .HasDefaultValue(new List()); builder.Entity() .Property(user => user.IdentityProvider) .HasDefaultValue(IdentityProvider.Kavita); + + builder.Entity() + .Property(a => a.SocialPreferences) + .HasJsonConversion(new AppUserSocialPreferences()) + .HasColumnType("TEXT") + .HasDefaultValue(new AppUserSocialPreferences()); + + builder.Entity() + .Property(a => a.Likes) + .HasJsonConversion(new HashSet()) + .HasColumnType("TEXT") + .HasDefaultValue(new HashSet()); } #nullable enable diff --git a/API/Data/Migrations/20251003110154_SocialAnnotations.Designer.cs b/API/Data/Migrations/20251003110154_SocialAnnotations.Designer.cs new file mode 100644 index 000000000..687585e5c --- /dev/null +++ b/API/Data/Migrations/20251003110154_SocialAnnotations.Designer.cs @@ -0,0 +1,3925 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +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("20251003110154_SocialAnnotations")] + partial class SocialAnnotations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + 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("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + 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("OidcId") + .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("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + 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.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Likes") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedSlotIndex") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserAnnotation"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + + 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.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("BookReaderHighlightSlots") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + 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("ColorScapeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("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("SocialPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true}"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("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("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + 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("ChapterTitle") + .HasColumnType("TEXT"); + + 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("SelectedText") + .HasColumnType("TEXT"); + + 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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.EpubFont", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("EpubFont"); + }); + + 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.History.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .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("KoreaderHash") + .HasColumnType("TEXT"); + + 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.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("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .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.HasIndex("ChapterId"); + + 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("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + 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.HasIndex("ChapterId"); + + 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("CbrId") + .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("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + 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.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.AppUserAnnotation", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .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("Chapter"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + 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.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .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.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.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + 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.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("Annotations"); + + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences") + .IsRequired(); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20251003110154_SocialAnnotations.cs b/API/Data/Migrations/20251003110154_SocialAnnotations.cs new file mode 100644 index 000000000..ecd522211 --- /dev/null +++ b/API/Data/Migrations/20251003110154_SocialAnnotations.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SocialAnnotations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SocialPreferences", + table: "AppUserPreferences", + type: "TEXT", + nullable: true, + defaultValue: "{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true}"); + + migrationBuilder.AddColumn( + name: "Likes", + table: "AppUserAnnotation", + type: "TEXT", + nullable: true, + defaultValue: "[]"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserAnnotation_LibraryId", + table: "AppUserAnnotation", + column: "LibraryId"); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserAnnotation_Library_LibraryId", + table: "AppUserAnnotation", + column: "LibraryId", + principalTable: "Library", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AppUserAnnotation_Library_LibraryId", + table: "AppUserAnnotation"); + + migrationBuilder.DropIndex( + name: "IX_AppUserAnnotation_LibraryId", + table: "AppUserAnnotation"); + + migrationBuilder.DropColumn( + name: "SocialPreferences", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "Likes", + table: "AppUserAnnotation"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index eae94e377..850ad5130 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -213,6 +213,11 @@ namespace API.Data.Migrations b.Property("LibraryId") .HasColumnType("INTEGER"); + b.Property("Likes") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + b.Property("PageNumber") .HasColumnType("INTEGER"); @@ -237,6 +242,8 @@ namespace API.Data.Migrations b.HasIndex("ChapterId"); + b.HasIndex("LibraryId"); + b.HasIndex("SeriesId"); b.ToTable("AppUserAnnotation"); @@ -614,6 +621,11 @@ namespace API.Data.Migrations b.Property("ShowScreenHints") .HasColumnType("INTEGER"); + b.Property("SocialPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true}"); + b.Property("SwipeToPaginate") .HasColumnType("INTEGER"); @@ -2988,6 +3000,12 @@ namespace API.Data.Migrations .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") @@ -2998,6 +3016,8 @@ namespace API.Data.Migrations b.Navigation("Chapter"); + b.Navigation("Library"); + b.Navigation("Series"); }); diff --git a/API/Data/Repositories/AnnotationRepository.cs b/API/Data/Repositories/AnnotationRepository.cs index 1918023e9..e0bcc802f 100644 --- a/API/Data/Repositories/AnnotationRepository.cs +++ b/API/Data/Repositories/AnnotationRepository.cs @@ -2,11 +2,14 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data.Misc; +using API.DTOs; using API.DTOs.Filtering.v2; using API.DTOs.Metadata.Browse.Requests; using API.DTOs.Annotations; using API.DTOs.Reader; using API.Entities; +using API.Entities.Enums; using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions.Filtering; using API.Helpers; @@ -26,10 +29,12 @@ public interface IAnnotationRepository void Remove(IEnumerable annotations); Task GetAnnotationDto(int id); Task GetAnnotation(int id); - Task> GetAnnotations(IList ids); + Task> GetAllAnnotations(); + Task> GetAnnotations(int userId, IList ids); Task> GetFullAnnotationsByUserIdAsync(int userId); Task> GetFullAnnotations(int userId, IList annotationIds); Task> GetAnnotationDtos(int userId, BrowseAnnotationFilterDto filter, UserParams userParams); + Task> GetSeriesWithAnnotations(int userId); } public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnotationRepository @@ -67,10 +72,18 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota .FirstOrDefaultAsync(a => a.Id == id); } - public async Task> GetAnnotations(IList ids) + public async Task> GetAllAnnotations() { + return await context.AppUserAnnotation.ToListAsync(); + } + + public async Task> GetAnnotations(int userId, IList ids) + { + var userPreferences = await context.AppUserPreferences.ToListAsync(); + return await context.AppUserAnnotation .Where(a => ids.Contains(a.Id)) + .RestrictBySocialPreferences(userId, userPreferences) .ToListAsync(); } @@ -80,26 +93,44 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota return await PagedList.CreateAsync(query, userParams); } + public async Task> GetSeriesWithAnnotations(int userId) + { + var userPreferences = await context.AppUserPreferences.ToListAsync(); + + var libraryIds = context.AppUser.GetLibraryIdsForUser(userId); + var userRating = await context.AppUser.GetUserAgeRestriction(userId); + + var seriesIdsWithAnnotations = await context.AppUserAnnotation + .RestrictBySocialPreferences(userId, userPreferences) + .Select(a => a.SeriesId) + .ToListAsync(); + + return await context.Series + .Where(s => libraryIds.Contains(s.LibraryId) && seriesIdsWithAnnotations.Contains(s.Id)) + .RestrictAgainstAgeRestriction(userRating) + .ProjectTo(mapper.ConfigurationProvider) + .ToListAsync(); + + } + private async Task> CreatedFilteredAnnotationQueryable(int userId, BrowseAnnotationFilterDto filter) { var allLibrariesCount = await context.Library.CountAsync(); var userLibs = await context.Library.GetUserLibraries(userId).ToListAsync(); + var seriesIds = await context.Series + .Where(s => userLibs.Contains(s.LibraryId)) + .Select(s => s.Id) + .ToListAsync(); - var seriesIds = await context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + var userPreferences = await context.AppUserPreferences.ToListAsync(); var query = context.AppUserAnnotation.AsNoTracking(); query = BuildAnnotationFilterQuery(userId, filter, query); - var validUsers = await context.AppUserPreferences - .Where(a => a.AppUserId == userId) // TODO: Remove when the below is done - .Where(p => true) // TODO: Filter on sharing annotations preference - .Select(p => p.AppUserId) - .ToListAsync(); - - query = query.Where(a => validUsers.Contains(a.AppUserId)) - .WhereIf(allLibrariesCount != userLibs.Count, - a => seriesIds.Contains(a.SeriesId)); + query = query + .WhereIf(allLibrariesCount != userLibs.Count, a => seriesIds.Contains(a.SeriesId)) + .RestrictBySocialPreferences(userId, userPreferences); var sortedQuery = query.SortBy(filter.SortOptions); var limitedQuery = filter.LimitTo <= 0 ? sortedQuery : sortedQuery.Take(filter.LimitTo); @@ -140,6 +171,7 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota { AnnotationFilterField.Owner => query.IsOwnedBy(true, statement.Comparison, (IList) value), AnnotationFilterField.Library => query.IsInLibrary(true, statement.Comparison, (IList) value), + AnnotationFilterField.Series => query.HasSeries(true, statement.Comparison, (IList) value), AnnotationFilterField.HighlightSlot => query.IsUsingHighlights(true, statement.Comparison, (IList) value), AnnotationFilterField.Spoiler => query.Where(a => !(bool) value || !a.ContainsSpoiler), AnnotationFilterField.Comment => query.HasCommented(true, statement.Comparison, (string) value), @@ -150,12 +182,14 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota public async Task> GetFullAnnotations(int userId, IList annotationIds) { + var userPreferences = await context.AppUserPreferences.ToListAsync(); + return await context.AppUserAnnotation .AsNoTracking() .Where(a => annotationIds.Contains(a.Id)) - .Where(a => a.AppUserId == userId) - //.Where(a => a.AppUserId == userId || a.AppUser.UserPreferences.ShareAnnotations) TODO: Filter out annotations for users who don't share them - .SelectFullAnnotation() + .RestrictBySocialPreferences(userId, userPreferences) + .ProjectTo(mapper.ConfigurationProvider) + .OrderFullAnnotation() .ToListAsync(); } @@ -166,9 +200,12 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota /// public async Task> GetFullAnnotationsByUserIdAsync(int userId) { + var userPreferences = await context.AppUserPreferences.ToListAsync(); + return await context.AppUserAnnotation - .Where(a => a.AppUserId == userId) - .SelectFullAnnotation() + .RestrictBySocialPreferences(userId, userPreferences) + .ProjectTo(mapper.ConfigurationProvider) + .OrderFullAnnotation() .ToListAsync(); } } diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 94a2198e4..721dec4d7 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -436,7 +436,7 @@ public class ReadingListRepository : IReadingListRepository if (userParams != null) { query = query - .Skip(userParams.PageNumber * userParams.PageSize) + .Skip((userParams.PageNumber - 1) * userParams.PageSize) // NOTE: PageNumber starts at 1 with PagedList, so copy logic here .Take(userParams.PageSize); } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index b1c219a93..59ff6b550 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -966,7 +966,7 @@ public class SeriesRepository : ISeriesRepository var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(serverSettings.OnDeckProgressDays); var cutoffLastAddedPoint = DateTime.Now - TimeSpan.FromDays(serverSettings.OnDeckUpdateDays); - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard) + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard) .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); @@ -1555,7 +1555,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind) { - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var usersSeriesIds = _context.Series @@ -1582,7 +1582,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard) + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard) .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); @@ -1610,7 +1610,7 @@ public class SeriesRepository : ISeriesRepository /// public async Task> GetRediscover(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithProgress = _context.AppUserProgresses @@ -1630,7 +1630,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesForMangaFile(int mangaFileId, int userId) { - var libraryIds = GetLibraryIdsForUser(userId, 0, QueryContext.Search); + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId, 0, QueryContext.Search); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.MangaFile @@ -1647,7 +1647,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesForChapter(int chapterId, int userId) { - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Chapter .Where(m => m.Id == chapterId) @@ -1792,7 +1792,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) { - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId); var normalizedSeries = seriesName.ToNormalized(); var normalizedLocalized = localizedName.ToNormalized(); @@ -1826,7 +1826,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesByAnyName(IList names, IList formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) { - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId); names = names.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); var normalizedNames = names.Select(s => s.ToNormalized()).ToList(); @@ -1923,7 +1923,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetHighlyRated(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithHighRating = _context.AppUserRating @@ -1945,7 +1945,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetQuickReads(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithProgress = _context.AppUserProgresses @@ -1972,7 +1972,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) + var libraryIds = _context.AppUser.GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithProgress = _context.AppUserProgresses @@ -2343,36 +2343,8 @@ public class SeriesRepository : ISeriesRepository .Select(s => s.Metadata.AgeRating) .OrderBy(s => s) .LastOrDefaultAsync(); + if (ret == null) return AgeRating.Unknown; return ret; } - - /// - /// Returns all library ids for a user - /// - /// - /// 0 for no library filter - /// Defaults to None - The context behind this query, so appropriate restrictions can be placed - /// - private IQueryable GetLibraryIdsForUser(int userId, int libraryId = 0, QueryContext queryContext = QueryContext.None) - { - var user = _context.AppUser - .AsSplitQuery() - .AsNoTracking() - .Where(u => u.Id == userId) - .AsSingleQuery(); - - if (libraryId == 0) - { - return user.SelectMany(l => l.Libraries) - .IsRestricted(queryContext) - .Select(lib => lib.Id); - } - - return user.SelectMany(l => l.Libraries) - .Where(lib => lib.Id == libraryId) - .IsRestricted(queryContext) - .Select(lib => lib.Id); - } - } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 7732c5edc..a50d0a208 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -587,9 +587,11 @@ public class UserRepository : IUserRepository /// public async Task> GetAnnotations(int userId, int chapterId) { - // TODO: Check settings if I should include other user's annotations + var userPreferences = await _context.AppUserPreferences.ToListAsync(); + return await _context.AppUserAnnotation - .Where(a => a.AppUserId == userId && a.ChapterId == chapterId) + .Where(a => a.ChapterId == chapterId) + .RestrictBySocialPreferences(userId, userPreferences) .OrderBy(a => a.PageNumber) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -597,9 +599,11 @@ public class UserRepository : IUserRepository public async Task> GetAnnotationsByPage(int userId, int chapterId, int pageNum) { - // TODO: Check settings if I should include other user's annotations + var userPreferences = await _context.AppUserPreferences.ToListAsync(); + return await _context.AppUserAnnotation - .Where(a => a.AppUserId == userId && a.ChapterId == chapterId && a.PageNumber == pageNum) + .Where(a => a.ChapterId == chapterId && a.PageNumber == pageNum) + .RestrictBySocialPreferences(userId, userPreferences) .OrderBy(a => a.PageNumber) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -617,16 +621,22 @@ public class UserRepository : IUserRepository public async Task GetAnnotationDtoById(int userId, int annotationId) { + var userPreferences = await _context.AppUserPreferences.ToListAsync(); + return await _context.AppUserAnnotation - .Where(a => a.AppUserId == userId && a.Id == annotationId) + .Where(a => a.Id == annotationId) + .RestrictBySocialPreferences(userId, userPreferences) .ProjectTo(_mapper.ConfigurationProvider) .FirstOrDefaultAsync(); } public async Task> GetAnnotationDtosBySeries(int userId, int seriesId) { + var userPreferences = await _context.AppUserPreferences.ToListAsync(); + return await _context.AppUserAnnotation - .Where(a => a.AppUserId == userId && a.SeriesId == seriesId) + .Where(a => a.SeriesId == seriesId) + .RestrictBySocialPreferences(userId, userPreferences) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } @@ -686,10 +696,12 @@ public class UserRepository : IUserRepository public async Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId) { + var userPreferences = await _context.AppUserPreferences.ToListAsync(); + return await _context.AppUserRating .Include(r => r.AppUser) .Where(r => r.SeriesId == seriesId) - .Where(r => r.AppUser.UserPreferences.ShareReviews || r.AppUserId == userId) + .RestrictBySocialPreferences(userId, userPreferences) .OrderBy(r => r.AppUserId == userId) .ThenBy(r => r.Rating) .AsSplitQuery() @@ -699,10 +711,12 @@ public class UserRepository : IUserRepository public async Task> GetUserRatingDtosForChapterAsync(int chapterId, int userId) { + var userPreferences = await _context.AppUserPreferences.ToListAsync(); + return await _context.AppUserChapterRating .Include(r => r.AppUser) .Where(r => r.ChapterId == chapterId) - .Where(r => r.AppUser.UserPreferences.ShareReviews || r.AppUserId == userId) + .RestrictBySocialPreferences(userId, userPreferences) .OrderBy(r => r.AppUserId == userId) .ThenBy(r => r.Rating) .AsSplitQuery() @@ -881,6 +895,7 @@ public class UserRepository : IUserRepository }, Libraries = u.Libraries.Select(l => new LibraryDto { + Id = l.Id, Name = l.Name, Type = l.Type, LastScanned = l.LastScanned, diff --git a/API/Entities/AppUserAnnotation.cs b/API/Entities/AppUserAnnotation.cs index 960c884ec..e3b7be90b 100644 --- a/API/Entities/AppUserAnnotation.cs +++ b/API/Entities/AppUserAnnotation.cs @@ -52,7 +52,10 @@ public class AppUserAnnotation : IEntityDate public bool ContainsSpoiler { get; set; } - // TODO: Figure out a simple mechansim to track upvotes (hashmap of userids?) + /// + /// A set container userIds of all users who have liked this annotations + /// + public ISet Likes { get; set; } = new HashSet(); /// /// Title of the TOC Chapter within Epub (not Chapter Entity) @@ -60,6 +63,7 @@ public class AppUserAnnotation : IEntityDate public string? ChapterTitle { get; set; } public required int LibraryId { get; set; } + public Library Library { get; set; } public required int SeriesId { get; set; } public Series Series { get; set; } public required int VolumeId { get; set; } diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index e125e14e9..25b4f61a7 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Data; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; @@ -163,10 +164,6 @@ public class AppUserPreferences /// public bool CollapseSeriesRelationships { get; set; } = false; /// - /// UI Site Global Setting: Should series reviews be shared with all users in the server - /// - public bool ShareReviews { get; set; } = false; - /// /// UI Site Global Setting: The language locale that should be used for the user /// public string Locale { get; set; } @@ -185,8 +182,60 @@ public class AppUserPreferences /// Should this account have Want to Read Sync enabled /// public bool WantToReadSync { get; set; } + #endregion + + #region Social + + /// + /// UI Site Global Setting: Should series reviews be shared with all users in the server + /// + [Obsolete("Use SocialPreferences.ShareReviews")] + public bool ShareReviews { get; set; } = false; + + /// + /// The social preferences of the AppUser + /// + /// Saved as a JSON obj in the DB + public AppUserSocialPreferences SocialPreferences { get; set; } = new(); + + + #endregion public AppUser AppUser { get; set; } = null!; public int AppUserId { get; set; } } + +public class AppUserSocialPreferences +{ + /// + /// UI Site Global Setting: Should series reviews be shared with all users in the server + /// + public bool ShareReviews { get; set; } = false; + + /// + /// UI Site Global Setting: Share your annotations with other users + /// + public bool ShareAnnotations { get; set; } = false; + + /// + /// UI Site Global Setting: See other users' annotations while reading + /// + public bool ViewOtherAnnotations { get; set; } = false; + + /// + /// UI Site Global Setting: For which libraries should social features be enabled + /// + /// Empty array means all, disable specific social features to opt out everywhere + public IList SocialLibraries { get; set; } = []; + + /// + /// UI Site Global Setting: Highest age rating for which social features are enabled + /// + public AgeRating SocialMaxAgeRating { get; set; } = AgeRating.NotApplicable; + + /// + /// UI Site Global Setting: Enable social features for unknown age ratings + /// + public bool SocialIncludeUnknowns { get; set; } = true; +} diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index fe3646943..86ce32b04 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -22,7 +22,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasKP /// Smallest number of the Range. Can be a partial like Chapter 4.5 /// [Obsolete("Use MinNumber and MaxNumber instead")] - public required string Number { get; set; } + public string Number { get; set; } /// /// Minimum Chapter Number. /// diff --git a/API/Extensions/DataContextExtensions.cs b/API/Extensions/DataContextExtensions.cs new file mode 100644 index 000000000..abf076861 --- /dev/null +++ b/API/Extensions/DataContextExtensions.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace API.Extensions; + +public static class DataContextExtensions +{ + + public static PropertyBuilder HasJsonConversion(this PropertyBuilder builder, TProperty def = default) + { + return builder.HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize(v, JsonSerializerOptions.Default) ?? def + ); + } + +} diff --git a/API/Extensions/QueryExtensions/Filtering/AnnotationFilter.cs b/API/Extensions/QueryExtensions/Filtering/AnnotationFilter.cs index 443757fac..0f9265e2f 100644 --- a/API/Extensions/QueryExtensions/Filtering/AnnotationFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/AnnotationFilter.cs @@ -35,10 +35,25 @@ public static class AnnotationFilter return comparison switch { - FilterComparison.Equal => queryable.Where(a => a.Series.LibraryId == libraryIds[0]), - FilterComparison.Contains => queryable.Where(a => libraryIds.Contains(a.Series.LibraryId)), - FilterComparison.NotContains => queryable.Where(a => !libraryIds.Contains(a.Series.LibraryId)), - FilterComparison.NotEqual => queryable.Where(a => a.Series.LibraryId != libraryIds[0]), + FilterComparison.Equal => queryable.Where(a => a.LibraryId == libraryIds[0]), + FilterComparison.Contains => queryable.Where(a => libraryIds.Contains(a.LibraryId)), + FilterComparison.NotContains => queryable.Where(a => !libraryIds.Contains(a.LibraryId)), + FilterComparison.NotEqual => queryable.Where(a => a.LibraryId != libraryIds[0]), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null), + }; + } + + public static IQueryable HasSeries(this IQueryable queryable, bool condition, + FilterComparison comparison, IList seriesIds) + { + if (seriesIds.Count == 0 || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(a => a.SeriesId == seriesIds[0]), + FilterComparison.Contains => queryable.Where(a => seriesIds.Contains(a.SeriesId)), + FilterComparison.NotContains => queryable.Where(a => !seriesIds.Contains(a.SeriesId)), + FilterComparison.NotEqual => queryable.Where(a => a.SeriesId != seriesIds[0]), _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null), }; } @@ -50,7 +65,7 @@ public static class AnnotationFilter return comparison switch { - FilterComparison.Equal => queryable.Where(a => a.SelectedSlotIndex== highlightSlotIdxs[0]), + FilterComparison.Equal => queryable.Where(a => a.SelectedSlotIndex == highlightSlotIdxs[0]), FilterComparison.Contains => queryable.Where(a => highlightSlotIdxs.Contains(a.SelectedSlotIndex)), FilterComparison.NotContains => queryable.Where(a => !highlightSlotIdxs.Contains(a.SelectedSlotIndex)), FilterComparison.NotEqual => queryable.Where(a => a.SelectedSlotIndex != highlightSlotIdxs[0]), diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index 7990056ec..df9663607 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -14,6 +14,7 @@ using API.Entities; using API.Entities.Enums; using API.Entities.Person; using API.Entities.Scrobble; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions; @@ -90,6 +91,34 @@ public static class QueryableExtensions .Select(lib => lib.Id); } + /// + /// Returns all library ids for a user + /// + /// + /// 0 for no library filter + /// Defaults to None - The context behind this query, so appropriate restrictions can be placed + /// + public static IQueryable GetLibraryIdsForUser(this DbSet query, int userId, int libraryId = 0, QueryContext queryContext = QueryContext.None) + { + var user = query + .AsSplitQuery() + .AsNoTracking() + .Where(u => u.Id == userId) + .AsSingleQuery(); + + if (libraryId == 0) + { + return user.SelectMany(l => l.Libraries) + .IsRestricted(queryContext) + .Select(lib => lib.Id); + } + + return user.SelectMany(l => l.Libraries) + .Where(lib => lib.Id == libraryId) + .IsRestricted(queryContext) + .Select(lib => lib.Id); + } + /// /// Returns all libraries for a given user and library type /// @@ -346,31 +375,9 @@ public static class QueryableExtensions }; } - public static IQueryable SelectFullAnnotation(this IQueryable query) + public static IQueryable OrderFullAnnotation(this IQueryable query) { - return query.Select(a => new FullAnnotationDto - { - Id = a.Id, - UserId = a.AppUserId, - SelectedText = a.SelectedText, - Comment = a.Comment, - CommentHtml = a.CommentHtml, - CommentPlainText = a.CommentPlainText, - Context = a.Context, - ChapterTitle = a.ChapterTitle, - PageNumber = a.PageNumber, - SelectedSlotIndex = a.SelectedSlotIndex, - ContainsSpoiler = a.ContainsSpoiler, - CreatedUtc = a.CreatedUtc, - LastModifiedUtc = a.LastModifiedUtc, - LibraryId = a.LibraryId, - LibraryName = a.Chapter.Volume.Series.Library.Name, - SeriesId = a.SeriesId, - SeriesName = a.Chapter.Volume.Series.Name, - VolumeId = a.VolumeId, - VolumeName = a.Chapter.Volume.Name, - ChapterId = a.ChapterId, - }) + return query .OrderBy(a => a.SeriesId) .ThenBy(a => a.VolumeId) .ThenBy(a => a.ChapterId) diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index e0738bdf3..ad609703c 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using API.Data.Misc; using API.Entities; @@ -151,4 +152,199 @@ public static class RestrictByAgeExtensions return q; } + + private static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction, int userId) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(r => r.Series.Metadata.AgeRating <= restriction.AgeRating || r.AppUserId == userId); + + if (!restriction.IncludeUnknowns) + { + return q.Where(a => a.Series.Metadata.AgeRating != AgeRating.Unknown || a.AppUserId == userId); + } + + return q; + } + + private static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction, int userId) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(r => r.Series.Metadata.AgeRating <= restriction.AgeRating || r.AppUserId == userId); + + if (!restriction.IncludeUnknowns) + { + return q.Where(a => a.Series.Metadata.AgeRating != AgeRating.Unknown || a.AppUserId == userId); + } + + return q; + } + + private static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction, int userId) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(a => a.Series.Metadata.AgeRating <= restriction.AgeRating || a.AppUserId == userId); + + if (!restriction.IncludeUnknowns) + { + return q.Where(a => a.Series.Metadata.AgeRating != AgeRating.Unknown || a.AppUserId == userId); + } + + return q; + } + + // TODO: After updating to .net 10, leverage new Complex Data type queries to inline all db operations here + /// + /// Filter annotations by social preferences of users + /// + /// + /// + /// List of user preferences for every user on the server + /// + public static IQueryable RestrictBySocialPreferences(this IQueryable queryable, int userId, IList userPreferences) + { + var preferencesById = userPreferences.ToDictionary(p => p.AppUserId, p => p.SocialPreferences); + var socialPreferences = preferencesById[userId]; + + if (socialPreferences.ViewOtherAnnotations) + { + // We are unable to do dictionary lookups in Sqlite; This means we need to translate them to X IN Y. + var sharingUserIds = userPreferences + .Where(p => p.SocialPreferences.ShareAnnotations) + .Select(p => p.AppUserId) + .ToHashSet(); + + // Only include the users' annotations, or those of users that are sharing + queryable = queryable.Where(a => a.AppUserId == userId || sharingUserIds.Contains(a.AppUserId)); + + // For other users' annotation + foreach (var sharingUserId in sharingUserIds.Where(id => id != userId)) + { + // Filter out libs if enabled + var libs = preferencesById[sharingUserId].SocialLibraries; + if (libs.Count > 0) + { + queryable = queryable.Where(a => a.AppUserId != sharingUserId || libs.Contains(a.LibraryId)); + } + + // Filter on age rating + var ageRating = preferencesById[sharingUserId].SocialMaxAgeRating; + var includeUnknowns = preferencesById[sharingUserId].SocialIncludeUnknowns; + if (ageRating != AgeRating.NotApplicable) + { + queryable = queryable.Where(a => a.AppUserId != sharingUserId || a.Series.Metadata.AgeRating <= ageRating) + .WhereIf(!includeUnknowns, + a => a.AppUserId != sharingUserId || a.Series.Metadata.AgeRating != AgeRating.Unknown); + } + } + } + else + { + queryable = queryable.Where(a => a.AppUserId == userId); + } + + return queryable + .WhereIf(socialPreferences.SocialLibraries.Count > 0, + a => a.AppUserId == userId || socialPreferences.SocialLibraries.Contains(a.LibraryId)) + .RestrictAgainstAgeRestriction(new AgeRestriction + { + AgeRating = socialPreferences.SocialMaxAgeRating, + IncludeUnknowns = socialPreferences.SocialIncludeUnknowns, + }, userId); + } + + // TODO: After updating to .net 10, leverage new Complex Data type queries to inline all db operations here + /// + /// Filter user reviews social preferences of users + /// + /// + /// + /// List of user preferences for every user on the server + /// + public static IQueryable RestrictBySocialPreferences(this IQueryable queryable, int userId, IList userPreferences) + { + var preferencesById = userPreferences.ToDictionary(p => p.AppUserId, p => p.SocialPreferences); + var socialPreferences = preferencesById[userId]; + + var sharingUserIds = userPreferences + .Where(p => p.SocialPreferences.ShareReviews) + .Select(p => p.AppUserId) + .ToHashSet(); + + queryable = queryable.Where(r => r.AppUserId == userId || sharingUserIds.Contains(r.AppUserId)); + + foreach (var sharingUserId in sharingUserIds.Where(id => id != userId)) + { + var libs = preferencesById[sharingUserId].SocialLibraries; + if (libs.Count > 0) + { + queryable = queryable.Where(r => r.AppUserId != sharingUserId || libs.Contains(r.Series.LibraryId)); + } + + var ageRating = preferencesById[sharingUserId].SocialMaxAgeRating; + var includeUnknowns = preferencesById[sharingUserId].SocialIncludeUnknowns; + if (ageRating != AgeRating.NotApplicable) + { + queryable = queryable.Where(r => r.AppUserId != sharingUserId || r.Series.Metadata.AgeRating <= ageRating) + .WhereIf(!includeUnknowns, + r => r.AppUserId != sharingUserId || r.Series.Metadata.AgeRating != AgeRating.Unknown); + } + } + + return queryable + .WhereIf(socialPreferences.SocialLibraries.Count > 0, + r => r.AppUserId == userId || socialPreferences.SocialLibraries.Contains(r.Series.LibraryId)) + .RestrictAgainstAgeRestriction(new AgeRestriction + { + AgeRating = socialPreferences.SocialMaxAgeRating, + IncludeUnknowns = socialPreferences.SocialIncludeUnknowns, + }, userId); + } + + // TODO: After updating to .net 10, leverage new Complex Data type queries to inline all db operations here + /// + /// Filter user chapter reviews social preferences of users + /// + /// + /// + /// List of user preferences for every user on the server + /// + public static IQueryable RestrictBySocialPreferences(this IQueryable queryable, int userId, IList userPreferences) + { + var preferencesById = userPreferences.ToDictionary(p => p.AppUserId, p => p.SocialPreferences); + var socialPreferences = preferencesById[userId]; + + var sharingUserIds = userPreferences + .Where(p => p.SocialPreferences.ShareReviews) + .Select(p => p.AppUserId) + .ToHashSet(); + + queryable = queryable.Where(r => r.AppUserId == userId || sharingUserIds.Contains(r.AppUserId)); + + foreach (var sharingUserId in sharingUserIds.Where(id => id != userId)) + { + var libs = preferencesById[sharingUserId].SocialLibraries; + if (libs.Count > 0) + { + queryable = queryable.Where(r => r.AppUserId != sharingUserId || libs.Contains(r.Series.LibraryId)); + } + + var ageRating = preferencesById[sharingUserId].SocialMaxAgeRating; + var includeUnknowns = preferencesById[sharingUserId].SocialIncludeUnknowns; + if (ageRating != AgeRating.NotApplicable) + { + queryable = queryable.Where(r => r.AppUserId != sharingUserId || r.Series.Metadata.AgeRating <= ageRating) + .WhereIf(!includeUnknowns, + r => r.AppUserId != sharingUserId || r.Series.Metadata.AgeRating != AgeRating.Unknown); + } + } + + return queryable + .WhereIf(socialPreferences.SocialLibraries.Count > 0, + r => r.AppUserId == userId || socialPreferences.SocialLibraries.Contains(r.Series.LibraryId)) + .RestrictAgainstAgeRestriction(new AgeRestriction + { + AgeRating = socialPreferences.SocialMaxAgeRating, + IncludeUnknowns = socialPreferences.SocialIncludeUnknowns, + }, userId); + } } diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs index f2f69d7f3..2fdaf52a1 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -101,8 +101,7 @@ public static partial class StringExtensions return []; } - return value.Split(',') - .Where(s => !string.IsNullOrEmpty(s)) + return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(int.Parse) .ToList(); } @@ -115,11 +114,17 @@ public static partial class StringExtensions public static long ParseHumanReadableBytes(this string input) { if (string.IsNullOrWhiteSpace(input)) + { throw new ArgumentException("Input cannot be null or empty.", nameof(input)); + } + var match = HumanReadableBytesRegex().Match(input); if (!match.Success) + { throw new FormatException($"Invalid format: '{input}'"); + } + var value = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); var unit = match.Groups[2].Value.ToUpperInvariant(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 6c5807aad..ecfcd5c97 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -4,6 +4,7 @@ using System.Linq; using API.Data.Migrations; using API.DTOs; using API.DTOs.Account; +using API.DTOs.Annotations; using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Dashboard; @@ -391,7 +392,14 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName)) - .ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId)) ; + .ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId)) + .ForMember(dest => dest.SeriesName, opt => opt.MapFrom(src => src.Series.Name)) + .ForMember(dest => dest.LibraryName, opt => opt.MapFrom(src => src.Library.Name)); + + CreateMap() + .ForMember(dest => dest.SeriesName, opt => opt.MapFrom(src => src.Series.Name)) + .ForMember(dest => dest.VolumeName, opt => opt.MapFrom(src => src.Chapter.Volume.Name)) + .ForMember(dest => dest.LibraryName, opt => opt.MapFrom(src => src.Library.Name)); CreateMap(); } diff --git a/API/Helpers/Builders/AppUserBuilder.cs b/API/Helpers/Builders/AppUserBuilder.cs index 4fdea81c4..82469ebfe 100644 --- a/API/Helpers/Builders/AppUserBuilder.cs +++ b/API/Helpers/Builders/AppUserBuilder.cs @@ -23,16 +23,17 @@ public class AppUserBuilder : IEntityBuilder UserPreferences = new AppUserPreferences { Theme = theme ?? Seed.DefaultThemes.First(), + Locale = "en" }, - ReadingLists = new List(), - Bookmarks = new List(), - Libraries = new List(), - Ratings = new List(), - Progresses = new List(), - Devices = new List(), + ReadingLists = [], + Bookmarks = [], + Libraries = [], + Ratings = [], + Progresses = [], + Devices = [], Id = 0, - DashboardStreams = new List(), - SideNavStreams = new List(), + DashboardStreams = [], + SideNavStreams = [], ReadingProfiles = [], }; } @@ -65,7 +66,7 @@ public class AppUserBuilder : IEntityBuilder public AppUserBuilder WithRole(string role) { - _appUser.UserRoles ??= new List(); + _appUser.UserRoles ??= []; _appUser.UserRoles.Add(new AppUserRole() {Role = new AppRole() {Name = role}}); return this; } diff --git a/API/Helpers/Converters/AnnotationFilterFieldValueConverter.cs b/API/Helpers/Converters/AnnotationFilterFieldValueConverter.cs index d31e27389..23b0b6f07 100644 --- a/API/Helpers/Converters/AnnotationFilterFieldValueConverter.cs +++ b/API/Helpers/Converters/AnnotationFilterFieldValueConverter.cs @@ -14,7 +14,8 @@ public static class AnnotationFilterFieldValueConverter { AnnotationFilterField.Owner or AnnotationFilterField.HighlightSlot or - AnnotationFilterField.Library => value.ParseIntArray(), + AnnotationFilterField.Library or + AnnotationFilterField.Series => value.ParseIntArray(), AnnotationFilterField.Spoiler => bool.Parse(value), AnnotationFilterField.Selection => value, AnnotationFilterField.Comment => value, diff --git a/API/Services/AnnotationService.cs b/API/Services/AnnotationService.cs index 75d2bc3e2..11f4e861e 100644 --- a/API/Services/AnnotationService.cs +++ b/API/Services/AnnotationService.cs @@ -33,23 +33,20 @@ public interface IAnnotationService Task ExportAnnotations(int userId, IList? annotationIds = null); } -public class AnnotationService : IAnnotationService +public class AnnotationService( + ILogger logger, + IUnitOfWork unitOfWork, + IBookService bookService, + IEventHub eventHub) + : IAnnotationService { - private readonly IUnitOfWork _unitOfWork; - private readonly IBookService _bookService; - private readonly IDirectoryService _directoryService; - private readonly IEventHub _eventHub; - private readonly ILogger _logger; - public AnnotationService(IUnitOfWork unitOfWork, IBookService bookService, - IDirectoryService directoryService, IEventHub eventHub, ILogger logger) + private static readonly JsonSerializerOptions ExportJsonSerializerOptions = new() { - _unitOfWork = unitOfWork; - _bookService = bookService; - _directoryService = directoryService; - _eventHub = eventHub; - _logger = logger; - } + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; /// /// Create a new Annotation for the user against a Chapter @@ -67,12 +64,12 @@ public class AnnotationService : IAnnotationService throw new KavitaException("invalid-payload"); } - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId) ?? throw new KavitaException("chapter-doesnt-exist"); + var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId) ?? throw new KavitaException("chapter-doesnt-exist"); var chapterTitle = string.Empty; try { - var toc = await _bookService.GenerateTableOfContents(chapter); + var toc = await bookService.GenerateTableOfContents(chapter); var pageTocs = BookChapterItemHelper.GetTocForPage(toc, dto.PageNumber); if (pageTocs.Count > 0) { @@ -105,14 +102,14 @@ public class AnnotationService : IAnnotationService ChapterTitle = chapterTitle }; - _unitOfWork.AnnotationRepository.Attach(annotation); - await _unitOfWork.CommitAsync(); + unitOfWork.AnnotationRepository.Attach(annotation); + await unitOfWork.CommitAsync(); - return await _unitOfWork.AnnotationRepository.GetAnnotationDto(annotation.Id); + return (await unitOfWork.AnnotationRepository.GetAnnotationDto(annotation.Id))!; } catch (Exception ex) { - _logger.LogError(ex, "There was an exception when creating an annotation on {ChapterId} - Page {Page}", dto.ChapterId, dto.PageNumber); + logger.LogError(ex, "There was an exception when creating an annotation on {ChapterId} - Page {Page}", dto.ChapterId, dto.PageNumber); throw new KavitaException("annotation-failed-create"); } } @@ -128,7 +125,7 @@ public class AnnotationService : IAnnotationService { try { - var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(dto.Id); + var annotation = await unitOfWork.AnnotationRepository.GetAnnotation(dto.Id); if (annotation == null || annotation.AppUserId != userId) throw new KavitaException("denied"); annotation.ContainsSpoiler = dto.ContainsSpoiler; @@ -137,17 +134,19 @@ public class AnnotationService : IAnnotationService annotation.CommentHtml = dto.CommentHtml; annotation.CommentPlainText = StripHtml(dto.CommentHtml); - _unitOfWork.AnnotationRepository.Update(annotation); + unitOfWork.AnnotationRepository.Update(annotation); - if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) + if (!unitOfWork.HasChanges() || await unitOfWork.CommitAsync()) { - await _eventHub.SendMessageToAsync(MessageFactory.AnnotationUpdate, + dto = (await unitOfWork.AnnotationRepository.GetAnnotationDto(annotation.Id))!; + + await eventHub.SendMessageToAsync(MessageFactory.AnnotationUpdate, MessageFactory.AnnotationUpdateEvent(dto), userId); return dto; } } catch (Exception ex) { - _logger.LogError(ex, "There was an exception updating Annotation for Chapter {ChapterId} - Page {PageNumber}", dto.ChapterId, dto.PageNumber); + logger.LogError(ex, "There was an exception updating Annotation for Chapter {ChapterId} - Page {PageNumber}", dto.ChapterId, dto.PageNumber); } throw new KavitaException("generic-error"); @@ -158,7 +157,7 @@ public class AnnotationService : IAnnotationService try { // Get users with preferences for highlight colors - var users = (await _unitOfWork.UserRepository + var users = (await unitOfWork.UserRepository .GetAllUsersAsync(AppUserIncludes.UserPreferences)) .ToDictionary(u => u.Id, u => u); @@ -166,15 +165,15 @@ public class AnnotationService : IAnnotationService IList annotations; if (annotationIds == null) { - annotations = await _unitOfWork.AnnotationRepository.GetFullAnnotationsByUserIdAsync(userId); + annotations = await unitOfWork.AnnotationRepository.GetFullAnnotationsByUserIdAsync(userId); } else { - annotations = await _unitOfWork.AnnotationRepository.GetFullAnnotations(userId, annotationIds); + annotations = await unitOfWork.AnnotationRepository.GetFullAnnotations(userId, annotationIds); } // Get settings for hostname - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var hostname = !string.IsNullOrWhiteSpace(settings.HostName) ? settings.HostName : "http://localhost:5000"; // Group annotations by series, then by volume @@ -235,22 +234,15 @@ public class AnnotationService : IAnnotationService }).ToArray(); // Serialize to JSON - var options = new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping - }; + var json = JsonSerializer.Serialize(exportData, ExportJsonSerializerOptions); - var json = JsonSerializer.Serialize(exportData, options); - - _logger.LogInformation("Successfully exported {AnnotationCount} annotations for user {UserId}", annotations.Count, userId); + logger.LogInformation("Successfully exported {AnnotationCount} annotations for user {UserId}", annotations.Count, userId); return json; } catch (Exception ex) { - _logger.LogError(ex, "Failed to export annotations for user {UserId}", userId); + logger.LogError(ex, "Failed to export annotations for user {UserId}", userId); throw new KavitaException("annotation-export-failed"); } } @@ -271,7 +263,7 @@ public class AnnotationService : IAnnotationService } catch (Exception exception) { - _logger.LogError(exception, "Invalid html, cannot parse plain text"); + logger.LogError(exception, "Invalid html, cannot parse plain text"); return string.Empty; } } diff --git a/API/Services/OpdsService.cs b/API/Services/OpdsService.cs index f65d9dc41..acaf4e1a0 100644 --- a/API/Services/OpdsService.cs +++ b/API/Services/OpdsService.cs @@ -62,7 +62,16 @@ public class OpdsService : IOpdsService private readonly XmlSerializer _xmlSerializer; - private const int PageSize = 20; + public const int PageSize = 20; + public const int FirstPageNumber = 1; + public const string DefaultApiPrefix = "/api/opds/"; + + public const string NoReadingProgressIcon = "⭘"; + public const string QuarterReadingProgressIcon = "◔"; + public const string HalfReadingProgressIcon = "◑"; + public const string AboveHalfReadingProgressIcon = "◕"; + public const string FullReadingProgressIcon = "⬤"; + private readonly FilterV2Dto _filterV2Dto = new(); private readonly FilterDto _filterDto = new() { @@ -582,14 +591,14 @@ public class OpdsService : IOpdsService var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix); SetFeedId(feed, $"reading-list-{readingListId}"); - var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId, GetUserParams(request.PageNumber))).ToList(); var totalItems = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).Count(); // Check if there is reading progress or not, if so, inject a "continue-reading" item - var firstReadReadingListItem = items.FirstOrDefault(i => i.PagesRead > 0); - if (firstReadReadingListItem != null && request.PageNumber == 0) + var firstReadReadingListItem = items.FirstOrDefault(i => i.PagesRead > 0 && i.PagesRead != i.PagesTotal) ?? + items.FirstOrDefault(i => i.PagesRead == 0 && i.PagesRead != i.PagesTotal); + if (firstReadReadingListItem != null && request.PageNumber == FirstPageNumber) { await AddContinueReadingPoint(firstReadReadingListItem, feed, request); } @@ -724,8 +733,9 @@ public class OpdsService : IOpdsService var chapterDtos = await _unitOfWork.ChapterRepository.GetChapterDtoByIdsAsync(volume.Chapters.Select(c => c.Id), userId); // Check if there is reading progress or not, if so, inject a "continue-reading" item - var firstChapterWithProgress = chapterDtos.FirstOrDefault(c => c.PagesRead > 0); - if (firstChapterWithProgress != null) + var firstChapterWithProgress = chapterDtos.FirstOrDefault(i => i.PagesRead > 0 && i.PagesRead != i.Pages) ?? + chapterDtos.FirstOrDefault(i => i.PagesRead == 0 && i.PagesRead != i.Pages); + if (firstChapterWithProgress != null && request.PageNumber == FirstPageNumber) { var chapterDto = await _readerService.GetContinuePoint(seriesId, userId); await AddContinueReadingPoint(seriesId, chapterDto, feed, request); @@ -1221,19 +1231,22 @@ public class OpdsService : IOpdsService private static string GetReadingProgressIcon(int pagesRead, int totalPages) { - if (pagesRead == 0) return "⭘"; + if (pagesRead == 0) + { + return NoReadingProgressIcon; + } var percentageRead = (double)pagesRead / totalPages; return percentageRead switch { // 100% - >= 1.0 => "⬤", + >= 1.0 => FullReadingProgressIcon, // > 50% and < 100% - > 0.5 => "◕", + > 0.5 => AboveHalfReadingProgressIcon, // > 25% and <= 50% - > 0.25 => "◑", - _ => "◔" + > 0.25 => HalfReadingProgressIcon, + _ => QuarterReadingProgressIcon }; } diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 3343c090b..0223c7692 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -209,6 +209,7 @@ public class ReaderService : IReaderService if (user.Progresses == null) { + //throw new ArgumentException("AppUser must have Progress on it"); // TODO: Figure out the impact of switching to a more dev experience exception throw new KavitaException("progress-must-exist"); } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index da5294f5c..e9a3315d5 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -355,7 +355,7 @@ public class StatsService : IStatsService userDto.DevicePlatforms = user.Devices.Select(d => d.Platform).ToList(); userDto.SeriesBookmarksCreatedCount = user.Bookmarks.Count; userDto.SmartFilterCreatedCount = user.SmartFilters.Count; - userDto.IsSharingReviews = user.UserPreferences.ShareReviews; + userDto.IsSharingReviews = user.UserPreferences.SocialPreferences.ShareReviews; userDto.WantToReadSeriesCount = user.WantToRead.Count; userDto.IdentityProvider = user.IdentityProvider; diff --git a/API/redo-migration.sh b/API/redo-migration.sh new file mode 100755 index 000000000..76ef8fc7f --- /dev/null +++ b/API/redo-migration.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +migrations=($(ls -1 Data/Migrations/*.cs 2>/dev/null | grep -v "Designer.cs" | grep -v "Snapshot.cs" | sort -r)) + +if [ ${#migrations[@]} -lt 2 ]; then + echo "Error: Need at least 2 migrations to redo" + exit 1 +fi + +second_last=$(basename "${migrations[1]}" .cs) + +last=$(basename "${migrations[0]}" .cs) +last_name=$(echo "$last" | sed 's/^[0-9]*_//') + +echo "Rolling back to: $second_last" +echo "Removing and re-adding: $last_name" +read -p "Continue? (y/N) " -n 1 -r +echo "" + +if [[ $REPLY =~ ^[Yy]$ ]]; then + dotnet ef database update "$second_last" && \ + dotnet ef migrations remove && \ + dotnet ef migrations add "$last_name" +else + echo "Cancelled" + exit 0 +fi diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index a86ee6586..4b53f2fd6 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -48,6 +48,7 @@ "ngx-stars": "^1.6.5", "ngx-toastr": "^19.1.0", "nosleep.js": "^0.12.0", + "quill": "^2.0.3", "rxjs": "^7.8.2", "screenfull": "^6.0.2", "swiper": "^11.2.10", @@ -1077,6 +1078,7 @@ "version": "20.3.2", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.2.tgz", "integrity": "sha512-rLox2THiALVQqYGUaxZ6YD8qUoXIOGTw3s0tim9/U65GuXGRtYgG0ZQWYp3yjEBes0Ksx2/15eFPp1Ol4FdEKQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "7.28.3", @@ -1109,6 +1111,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1121,6 +1124,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1133,6 +1137,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^7.2.0", @@ -1147,12 +1152,14 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, "license": "MIT" }, "node_modules/@angular/compiler-cli/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -1170,6 +1177,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -1185,6 +1193,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -1202,6 +1211,7 @@ "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", @@ -1219,6 +1229,7 @@ "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, "license": "ISC", "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" @@ -6408,6 +6419,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -6580,7 +6592,8 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true }, "node_modules/cookie": { "version": "0.7.2", @@ -7096,6 +7109,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -7666,8 +7680,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -8191,7 +8204,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8786,23 +8799,20 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -10001,8 +10011,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/parent-module": { "version": "1.0.1", @@ -10365,7 +10374,6 @@ "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "eventemitter3": "^5.0.1", "lodash-es": "^4.17.21", @@ -10381,7 +10389,6 @@ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", "license": "MIT", - "peer": true, "dependencies": { "fast-diff": "^1.3.0", "lodash.clonedeep": "^4.5.0", @@ -10421,6 +10428,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -10433,7 +10441,8 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true }, "node_modules/require-from-string": { "version": "2.0.2", @@ -10642,7 +10651,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { "version": "1.90.0", @@ -10680,6 +10689,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11486,7 +11496,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/UI/Web/package.json b/UI/Web/package.json index 5987429a3..21eb882f4 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -59,6 +59,7 @@ "ngx-stars": "^1.6.5", "ngx-toastr": "^19.1.0", "nosleep.js": "^0.12.0", + "quill": "^2.0.3", "rxjs": "^7.8.2", "screenfull": "^6.0.2", "swiper": "^11.2.10", diff --git a/UI/Web/src/app/_helpers/browser.ts b/UI/Web/src/app/_helpers/browser.ts index 4d92e207c..d9200e19f 100644 --- a/UI/Web/src/app/_helpers/browser.ts +++ b/UI/Web/src/app/_helpers/browser.ts @@ -9,6 +9,68 @@ export const isSafari = [ // iPad on iOS 13 detection || (navigator.userAgent.includes("Mac") && "ontouchend" in document); +/** + * Detects if the browser is Chromium-based (Chrome, Edge, Opera, etc.) + */ +export const isChromiumBased = (): boolean => { + const userAgent = navigator.userAgent.toLowerCase(); + // Check for Chrome/Chromium indicators + return (userAgent.includes('chrome') || + userAgent.includes('crios') || // Chrome on iOS + userAgent.includes('chromium') || + userAgent.includes('edg/') || // Edge Chromium + userAgent.includes('opr/') || // Opera + userAgent.includes('samsungbrowser')) && + !userAgent.includes('firefox') && + !userAgent.includes('safari') || + (userAgent.includes('safari') && userAgent.includes('chrome')); // Chrome includes Safari in UA +}; + +/** + * Detects if the device is mobile or tablet + */ +export const isMobileDevice = (): boolean => { + // Check for touch capability and screen size + const hasTouchScreen = 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + (window.matchMedia && window.matchMedia('(pointer: coarse)').matches); + + // Additional mobile UA checks + const mobileUA = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test( + navigator.userAgent.toLowerCase() + ); + + // Screen width check for tablets + const isSmallScreen = window.innerWidth <= 1024; + + return hasTouchScreen && (mobileUA || isSmallScreen); +}; + +/** + * Detects if running on a Chromium-based mobile browser + */ +export const isMobileChromium = (): boolean => { + return isChromiumBased() && isMobileDevice(); +}; + +/** + * Gets the Chrome/Chromium version + */ +export const getChromiumVersion = (): Version | null => { + const userAgent = navigator.userAgent; + const matches = userAgent.match(/(?:Chrome|CriOS|Edg|OPR)\/(\d+)\.(\d+)\.(\d+)/); + + if (matches) { + return new Version( + parseInt(matches[1], 10), + parseInt(matches[2], 10), + parseInt(matches[3], 10) + ); + } + + return null; +}; + /** * Represents a Version for a browser */ @@ -48,7 +110,6 @@ export class Version { } } - export const getIosVersion = () => { const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/); if (match) { diff --git a/UI/Web/src/app/_models/metadata/v2/annotations-filter.ts b/UI/Web/src/app/_models/metadata/v2/annotations-filter.ts index dd2dab31b..a436f1def 100644 --- a/UI/Web/src/app/_models/metadata/v2/annotations-filter.ts +++ b/UI/Web/src/app/_models/metadata/v2/annotations-filter.ts @@ -8,6 +8,7 @@ export enum AnnotationsFilterField { HighlightSlots = 4, Selection = 5, Comment = 6, + Series = 7 } export const allAnnotationsFilterFields = Object.keys(AnnotationsFilterField) diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 8a98a2c92..be26da1b1 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,6 +1,7 @@ import {PageLayoutMode} from '../page-layout-mode'; import {SiteTheme} from './site-theme'; import {HighlightSlot} from "../../book-reader/_models/annotations/highlight-slot"; +import {AgeRating} from "../metadata/age-rating"; export interface Preferences { @@ -11,7 +12,6 @@ export interface Preferences { promptForDownloadSize: boolean; noTransitions: boolean; collapseSeriesRelationships: boolean; - shareReviews: boolean; locale: string; bookReaderHighlightSlots: HighlightSlot[]; colorScapeEnabled: boolean; @@ -19,5 +19,17 @@ export interface Preferences { // Kavita+ aniListScrobblingEnabled: boolean; wantToReadSync: boolean; + + // Social + socialPreferences: SocialPreferences; +} + +export interface SocialPreferences { + shareReviews: boolean; + shareAnnotations: boolean; + viewOtherAnnotations: boolean; + socialLibraries: number[]; + socialMaxAgeRating: AgeRating; + socialIncludeUnknowns: boolean; } diff --git a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts index 691d546a1..fd9262099 100644 --- a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts +++ b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts @@ -36,6 +36,8 @@ export class GenericFilterFieldPipe implements PipeTransform { return translate('filter-field-pipe.libraries'); case AnnotationsFilterField.Spoiler: return translate('generic-filter-field-pipe.annotation-spoiler'); + case AnnotationsFilterField.Series: + return translate('generic-filter-field-pipe.series'); } } diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 499fb81ed..9f6ac5b3f 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,5 +1,5 @@ import {HttpClient} from '@angular/common/http'; -import {DestroyRef, inject, Injectable} from '@angular/core'; +import {computed, DestroyRef, inject, Injectable} from '@angular/core'; import {Observable, of, ReplaySubject, shareReplay} from 'rxjs'; import {filter, map, switchMap, tap} from 'rxjs/operators'; import {environment} from 'src/environments/environment'; @@ -64,8 +64,10 @@ export class AccountService { if (!u) return false; return this.hasAdminRole(u); }), shareReplay({bufferSize: 1, refCount: true})); + public readonly isAdmin = toSignal(this.isAdmin$); - public readonly currentUserSignal = toSignal(this.currentUserSource); + public readonly currentUserSignal = toSignal(this.currentUser$); + public readonly userId = computed(() => this.currentUserSignal()?.id); /** * SetTimeout handler for keeping track of refresh token call @@ -189,7 +191,7 @@ export class AccountService { } getRoles() { - return this.httpClient.get(this.baseUrl + 'account/roles'); + return this.httpClient.get(this.baseUrl + 'account/roles'); } @@ -201,8 +203,7 @@ export class AccountService { if (user) { this.setCurrentUser(user); } - }), - takeUntilDestroyed(this.destroyRef) + }) ); } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index bd2d733d2..7b1d6aa9d 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -132,6 +132,8 @@ export enum Action { */ ClearReadingProfile = 31, Export = 32, + Like = 33, + UnLike = 34, } /** @@ -1126,7 +1128,25 @@ export class ActionFactoryService { shouldRender: this.dummyShouldRender, requiredRoles: [], children: [], - } + }, + { + action: Action.Like, + title: 'like', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiredRoles: [], + children: [], + }, + { + action: Action.UnLike, + title: 'unlike', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiredRoles: [], + children: [], + }, ]; diff --git a/UI/Web/src/app/_services/annotation.service.ts b/UI/Web/src/app/_services/annotation.service.ts index 8cf857e2b..2ffdba385 100644 --- a/UI/Web/src/app/_services/annotation.service.ts +++ b/UI/Web/src/app/_services/annotation.service.ts @@ -87,7 +87,7 @@ export class AnnotationService { return this.httpClient.post[]>(this.baseUrl + 'annotation/all-filtered', filter, {observe: 'response', params}).pipe( map((res: any) => { - return this.utilityService.createPaginatedResult(res as PaginatedResult[]); + return this.utilityService.createPaginatedResult(res); }), ); } @@ -114,7 +114,6 @@ export class AnnotationService { return this.httpClient.post(this.baseUrl + 'annotation/update', data).pipe( switchMap(newAnnotation => this.getAllAnnotations(data.chapterId)), tap(_ => { - console.log('emitting edit event'); this._events.set({ pageNumber: data.pageNumber, type: 'edit', @@ -201,4 +200,26 @@ export class AnnotationService { }) ); } + + /** + * Does not emit an event + * @param ids + */ + likeAnnotations(ids: number[]) { + const userId = this.accountService.currentUserSignal()?.id; + if (!userId) return of(); + + return this.httpClient.post(this.baseUrl + 'annotation/like', ids); + } + + /** + * Does not emit an event + * @param ids + */ + unLikeAnnotations(ids: number[]) { + const userId = this.accountService.currentUserSignal()?.id; + if (!userId) return of(); + + return this.httpClient.post(this.baseUrl + 'annotation/unlike', ids); + } } diff --git a/UI/Web/src/app/_services/epub-reader-settings.service.ts b/UI/Web/src/app/_services/epub-reader-settings.service.ts index 2e25d520e..dec0077a8 100644 --- a/UI/Web/src/app/_services/epub-reader-settings.service.ts +++ b/UI/Web/src/app/_services/epub-reader-settings.service.ts @@ -277,12 +277,12 @@ export class EpubReaderSettingsService { this._immersiveMode.set(profile.bookReaderImmersiveMode); // Set up page styles - this.setPageStyles( + this._pageStyles.set(this.buildPageStyles( profile.bookReaderFontFamily, profile.bookReaderFontSize + '%', profile.bookReaderMargin + 'vw', profile.bookReaderLineSpacing + '%' - ); + )); } /** @@ -597,12 +597,19 @@ export class EpubReaderSettingsService { */ resetSettings() { const defaultStyles = this.getDefaultPageStyles(); - this.setPageStyles( + + const styles = this.buildPageStyles( defaultStyles["font-family"], defaultStyles["font-size"], defaultStyles['margin-left'], defaultStyles['line-height'], ); + + // Update form to ensure RP is updated, forgive me for the replace... + this.settingsForm.get('bookReaderFontFamily')!.setValue(styles["font-family"]); + this.settingsForm.get('bookReaderFontSize')!.setValue(parseInt(styles["font-size"].replace("%", ""))); + this.settingsForm.get('bookReaderMargin')!.setValue(parseInt(styles["margin-left"].replace("vw", ""))); + this.settingsForm.get('bookReaderLineSpacing')!.setValue(parseInt(styles["line-height"].replace("%", ""))); } @@ -653,7 +660,7 @@ export class EpubReaderSettingsService { return data; } - private setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string): void { + private buildPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string) { const windowWidth = window.innerWidth || this.document.documentElement.clientWidth || this.document.body.clientWidth; const mobileBreakpointMarginOverride = 700; @@ -671,7 +678,7 @@ export class EpubReaderSettingsService { 'line-height': lineHeight || currentStyles['line-height'] || '100%' }; - this._pageStyles.set(newStyles); + return newStyles } public getDefaultPageStyles(): PageStyle { diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 598cb4138..dd3f48d8b 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -37,6 +37,7 @@ import {AnnotationsFilterField} from "../_models/metadata/v2/annotations-filter" import {AccountService} from "./account.service"; import {MemberService} from "./member.service"; import {RgbaColor} from "../book-reader/_models/annotations/highlight-slot"; +import {SeriesService} from "./series.service"; @Injectable({ providedIn: 'root' @@ -51,6 +52,7 @@ export class MetadataService { private readonly utilityService = inject(UtilityService); private readonly accountService = inject(AccountService); private readonly memberService = inject(MemberService) + private readonly seriesService = inject(SeriesService) private readonly highlightSlots = computed(() => { return this.accountService.currentUserSignal()?.preferences?.bookReaderHighlightSlots ?? []; @@ -280,6 +282,10 @@ export class MetadataService { return of(this.highlightSlots().map((slot, idx) => { return {value: slot.slotNumber, label: translate('highlight-bar.slot-label', {slot: slot.slotNumber + 1}), color: slot.color}; // Slots start at 0 })); + case AnnotationsFilterField.Series: + return this.seriesService.getSeriesWithAnnotations().pipe(map(series => series.map(s => { + return {value: s.id, label: s.name}; + }))); } return of([]); diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index b672f78ad..3ed4efb69 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -1,5 +1,5 @@ import {HttpClient, HttpParams} from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {Observable} from 'rxjs'; import {map} from 'rxjs/operators'; import {environment} from 'src/environments/environment'; @@ -224,4 +224,8 @@ export class SeriesService { updateDontMatch(seriesId: number, dontMatch: boolean) { return this.httpClient.post(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse); } + + getSeriesWithAnnotations() { + return this.httpClient.get(this.baseUrl + 'series/series-with-annotations'); + } } diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html index 4da6289c8..870662465 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html @@ -1,54 +1,18 @@
- @if (readingTime) { + @if (accountService.isAdmin() && filePaths && filePaths.length > 0) {
-

{{t('read-time-title')}}

-
- {{readingTime | readTime}} +

{{t('file-path-title')}}

+
+ @for (fp of filePaths; track $index) { + {{fp}} + }
} - @if (releaseYear) { -
-

{{t('release-title')}}

-
- {{releaseYear}} -
-
- } - - @if (language) { -
-

{{t('language-title')}}

-
- {{language | languageName | async}} -
-
- } - - @if (ageRating) { -
-

{{t('age-rating-title')}}

-
- -
-
- } - - @if (format) { -
-

{{t('format-title')}}

-
- {{format | mangaFormat }} -
-
- } - - @if (!suppressEmptyGenres || genres.length > 0) { -

{{t('genres-title')}}

diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts index 096826964..c6f4ebc38 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts @@ -22,6 +22,7 @@ import {AsyncPipe} from "@angular/common"; import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; import {AgeRating} from "../../_models/metadata/age-rating"; import {AgeRatingImageComponent} from "../age-rating-image/age-rating-image.component"; +import {AccountService} from "../../_services/account.service"; @Component({ selector: 'app-details-tab', @@ -31,13 +32,7 @@ import {AgeRatingImageComponent} from "../age-rating-image/age-rating-image.comp TranslocoDirective, ImageComponent, BadgeExpanderComponent, - ReadTimePipe, - SeriesFormatComponent, - MangaFormatPipe, - LanguageNamePipe, - AsyncPipe, SafeUrlPipe, - AgeRatingImageComponent ], templateUrl: './details-tab.component.html', styleUrl: './details-tab.component.scss', @@ -47,22 +42,19 @@ export class DetailsTabComponent { protected readonly imageService = inject(ImageService); private readonly filterUtilityService = inject(FilterUtilitiesService); + protected readonly accountService = inject(AccountService); protected readonly PersonRole = PersonRole; protected readonly FilterField = FilterField; protected readonly MangaFormat = MangaFormat; @Input({required: true}) metadata!: IHasCast; - @Input() readingTime: IHasReadingTime | undefined; - @Input() ageRating: AgeRating | undefined; - @Input() language: string | undefined; - @Input() format: MangaFormat | undefined; - @Input() releaseYear: number | undefined; @Input() genres: Array = []; @Input() tags: Array = []; @Input() webLinks: Array = []; @Input() suppressEmptyGenres: boolean = false; @Input() suppressEmptyTags: boolean = false; + @Input() filePaths: string[] | undefined; openGeneric(queryParamName: FilterField, filter: string | number) { diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index dc959fd32..7c4583f06 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -93,11 +93,19 @@
- +
- +
diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index b93a08092..9a698de0b 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -6,18 +6,16 @@ import { DestroyRef, inject, model, - OnInit + OnInit, signal } from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {AgeRestriction} from 'src/app/_models/metadata/age-restriction'; import {Library} from 'src/app/_models/library/library'; import {Member} from 'src/app/_models/auth/member'; -import {AccountService} from 'src/app/_services/account.service'; +import {AccountService, allRoles, Role} from 'src/app/_services/account.service'; import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe'; import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component'; -import {LibrarySelectorComponent} from '../library-selector/library-selector.component'; -import {RoleSelectorComponent} from '../role-selector/role-selector.component'; import {AsyncPipe} from '@angular/common'; import {TranslocoDirective} from "@jsverse/transloco"; import {debounceTime, distinctUntilChanged, Observable, startWith, tap} from "rxjs"; @@ -26,6 +24,11 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ServerSettings} from "../_models/server-settings"; import {IdentityProvider, IdentityProviders} from "../../_models/user"; import {IdentityProviderPipePipe} from "../../_pipes/identity-provider.pipe"; +import { + MultiCheckBoxItem, + SettingMultiCheckBox +} from "../../settings/_components/setting-multi-check-box/setting-multi-check-box.component"; +import {LibraryService} from "../../_services/library.service"; const AllowedUsernameCharacters = /^[a-zA-Z0-9\-._@+/]*$/; const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -34,7 +37,7 @@ const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; selector: 'app-edit-user', templateUrl: './edit-user.component.html', styleUrls: ['./edit-user.component.scss'], - imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, IdentityProviderPipePipe], + imports: [ReactiveFormsModule, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, IdentityProviderPipePipe, SettingMultiCheckBox], changeDetection: ChangeDetectionStrategy.OnPush }) export class EditUserComponent implements OnInit { @@ -43,6 +46,7 @@ export class EditUserComponent implements OnInit { private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); protected readonly modal = inject(NgbActiveModal); + private readonly libraryService = inject(LibraryService); member = model.required(); settings = model.required(); @@ -53,8 +57,16 @@ export class EditUserComponent implements OnInit { return setting.oidcConfig.syncUserSettings && member.identityProvider === IdentityProvider.OpenIdConnect; }); - selectedRoles: Array = []; - selectedLibraries: Array = []; + libraries = signal([]); + libraryOptions = computed[]>(() => this.libraries().map(l => { + return { label: l.name, value: l.id }; + })); + roleOptions: MultiCheckBoxItem[] = allRoles.map(r => { + return { label: r, value: r, disableFunc: (r: Role, selected: Role[]) => { + return r !== Role.Admin && selected.includes(Role.Admin); + }} + }); + selectedRestriction!: AgeRestriction; isSaving: boolean = false; @@ -66,14 +78,18 @@ export class EditUserComponent implements OnInit { public get email() { return this.userForm.get('email'); } public get username() { return this.userForm.get('username'); } public get password() { return this.userForm.get('password'); } - public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); }; + get hasAdminRoleSelected() { return this.userForm.get('roles')!.value.includes(Role.Admin); }; ngOnInit(): void { + this.libraryService.getLibraries().subscribe(libraries => this.libraries.set(libraries)); + this.userForm.addControl('email', new FormControl(this.member().email, [Validators.required])); this.userForm.addControl('username', new FormControl(this.member().username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)])); this.userForm.addControl('identityProvider', new FormControl(this.member().identityProvider, [Validators.required])); + this.userForm.addControl('roles', new FormControl(this.member().roles)); + this.userForm.addControl('libraries', new FormControl(this.member().libraries.map(l => l.id))); this.userForm.get('identityProvider')!.valueChanges.pipe( tap(value => { @@ -97,21 +113,11 @@ export class EditUserComponent implements OnInit { this.cdRef.markForCheck(); } - updateRoleSelection(roles: Array) { - this.selectedRoles = roles; - this.cdRef.markForCheck(); - } - updateRestrictionSelection(restriction: AgeRestriction) { this.selectedRestriction = restriction; this.cdRef.markForCheck(); } - updateLibrarySelection(libraries: Array) { - this.selectedLibraries = libraries.map(l => l.id); - this.cdRef.markForCheck(); - } - close() { this.modal.close(false); } @@ -119,8 +125,6 @@ export class EditUserComponent implements OnInit { save() { const model = this.userForm.getRawValue(); model.userId = this.member().id; - model.roles = this.selectedRoles; - model.libraries = this.selectedLibraries; model.ageRestriction = this.selectedRestriction; model.identityProvider = parseInt(model.identityProvider, 10) as IdentityProvider; diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html index d6a494595..b48438513 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.html +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -30,11 +30,19 @@
- +
- +
diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.ts b/UI/Web/src/app/admin/invite-user/invite-user.component.ts index d11742cb8..cb4cd47ad 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.ts +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.ts @@ -1,25 +1,28 @@ -import {ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; -import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {ChangeDetectorRef, Component, computed, inject, OnInit, signal} from '@angular/core'; +import {FormControl, FormGroup, NonNullableFormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; import {AgeRestriction} from 'src/app/_models/metadata/age-restriction'; import {InviteUserResponse} from 'src/app/_models/auth/invite-user-response'; import {Library} from 'src/app/_models/library/library'; import {AgeRating} from 'src/app/_models/metadata/age-rating'; -import {AccountService} from 'src/app/_services/account.service'; +import {AccountService, allRoles, Role} from 'src/app/_services/account.service'; import {ApiKeyComponent} from '../../user-settings/api-key/api-key.component'; import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component'; -import {LibrarySelectorComponent} from '../library-selector/library-selector.component'; -import {RoleSelectorComponent} from '../role-selector/role-selector.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; +import {LibraryService} from "../../_services/library.service"; +import { + MultiCheckBoxItem, + SettingMultiCheckBox +} from "../../settings/_components/setting-multi-check-box/setting-multi-check-box.component"; @Component({ selector: 'app-invite-user', templateUrl: './invite-user.component.html', styleUrls: ['./invite-user.component.scss'], - imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, - ApiKeyComponent, TranslocoDirective, SafeHtmlPipe] + imports: [ReactiveFormsModule,RestrictionSelectorComponent, + ApiKeyComponent, TranslocoDirective, SafeHtmlPipe, SettingMultiCheckBox] }) export class InviteUserComponent implements OnInit { @@ -27,29 +30,46 @@ export class InviteUserComponent implements OnInit { private readonly accountService = inject(AccountService); private readonly toastr = inject(ToastrService); protected readonly modal = inject(NgbActiveModal); + private readonly libraryService = inject(LibraryService); /** * Maintains if the backend is sending an email */ isSending: boolean = false; - inviteForm: FormGroup = new FormGroup({}); - selectedRoles: Array = []; - selectedLibraries: Array = []; + inviteForm: FormGroup<{ + email: FormControl, + libraries: FormControl, + roles: FormControl, + }> = new FormGroup({ + email: new FormControl(''), + libraries: new FormControl([]), + roles: new FormControl([Role.Login]), + }) as any; selectedRestriction: AgeRestriction = {ageRating: AgeRating.NotApplicable, includeUnknowns: false}; emailLink: string = ''; invited: boolean = false; inviteError: boolean = false; + libraries = signal([]); + libraryOptions = computed[]>(() => this.libraries().map(l => { + return { label: l.name, value: l.id }; + })); + roleOptions: MultiCheckBoxItem[] = allRoles.map(r => { + return { label: r, value: r, disableFunc: (r: Role, selected: Role[]) => { + return r !== Role.Admin && selected.includes(Role.Admin); + }} + }); + makeLink: (val: string) => string = (_: string) => {return this.emailLink}; - get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); }; + get hasAdminRoleSelected() { return this.inviteForm.get('roles')!.value.includes(Role.Admin); }; get email() { return this.inviteForm.get('email'); } ngOnInit(): void { - this.inviteForm.addControl('email', new FormControl('', [Validators.required])); + this.libraryService.getLibraries().subscribe(libraries => this.libraries.set(libraries)); } close() { @@ -58,11 +78,11 @@ export class InviteUserComponent implements OnInit { invite() { this.isSending = true; - const email = this.inviteForm.get('email')?.value.trim(); + + const email = this.inviteForm.get('email')!.value; + this.accountService.inviteUser({ - email, - libraries: this.selectedLibraries, - roles: this.selectedRoles, + ...this.inviteForm.getRawValue(), ageRestriction: this.selectedRestriction }).subscribe((data: InviteUserResponse) => { this.emailLink = data.emailLink; @@ -89,16 +109,6 @@ export class InviteUserComponent implements OnInit { }); } - updateRoleSelection(roles: Array) { - this.selectedRoles = roles; - this.cdRef.markForCheck(); - } - - updateLibrarySelection(libraries: Array) { - this.selectedLibraries = libraries.map(l => l.id); - this.cdRef.markForCheck(); - } - updateRestrictionSelection(restriction: AgeRestriction) { this.selectedRestriction = restriction; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.html b/UI/Web/src/app/admin/library-selector/library-selector.component.html deleted file mode 100644 index a005a8fd7..000000000 --- a/UI/Web/src/app/admin/library-selector/library-selector.component.html +++ /dev/null @@ -1,42 +0,0 @@ - - -
-
-

{{t('title')}}

-
-
- @if(!isLoading && allLibraries.length > 0) { - - - - - } -
-
- - @if (isLoading) { - - } @else { -
-
    - @for (library of allLibraries; track library.name; let i = $index) { -
  • -
    - - -
    -
  • - } @empty { -
  • - {{t('no-data')}} -
  • - } -
-
- } - - - -
diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.scss b/UI/Web/src/app/admin/library-selector/library-selector.component.scss deleted file mode 100644 index 3f2adc8d1..000000000 --- a/UI/Web/src/app/admin/library-selector/library-selector.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.list-group-item { - border: none; -} \ No newline at end of file diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.ts b/UI/Web/src/app/admin/library-selector/library-selector.component.ts deleted file mode 100644 index 3ece1a4ba..000000000 --- a/UI/Web/src/app/admin/library-selector/library-selector.component.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - inject, input, - Input, - OnInit, - Output -} from '@angular/core'; -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {Library} from 'src/app/_models/library/library'; -import {Member} from 'src/app/_models/auth/member'; -import {LibraryService} from 'src/app/_services/library.service'; -import {TranslocoDirective} from "@jsverse/transloco"; -import {LoadingComponent} from "../../shared/loading/loading.component"; -import {SelectionModel} from "../../typeahead/_models/selection-model"; - -@Component({ - selector: 'app-library-selector', - templateUrl: './library-selector.component.html', - styleUrls: ['./library-selector.component.scss'], - imports: [ReactiveFormsModule, FormsModule, TranslocoDirective, LoadingComponent], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class LibrarySelectorComponent implements OnInit { - - private readonly libraryService = inject(LibraryService); - private readonly cdRef = inject(ChangeDetectorRef); - - @Input() member: Member | undefined; - preSelectedLibraries = input([]); - - @Output() selected: EventEmitter> = new EventEmitter>(); - - allLibraries: Library[] = []; - selectedLibraries: Array<{selected: boolean, data: Library}> = []; - selections!: SelectionModel; - selectAll: boolean = false; - isLoading: boolean = false; - - get hasSomeSelected() { - return this.selections != null && this.selections.hasSomeSelected(); - } - - - ngOnInit(): void { - this.libraryService.getLibraries().subscribe(libs => { - this.allLibraries = libs; - this.setupSelections(); - }); - } - - - setupSelections() { - this.selections = new SelectionModel(false, this.allLibraries); - this.isLoading = false; - - // If a member is passed in, then auto-select their libraries - if (this.member !== undefined) { - this.member.libraries.forEach(lib => { - this.selections.toggle(lib, true, (a, b) => a.name === b.name); - }); - this.selectAll = this.selections.selected().length === this.allLibraries.length; - this.selected.emit(this.selections.selected()); - } else if (this.preSelectedLibraries().length > 0) { - this.preSelectedLibraries().forEach((id) => { - const foundLib = this.allLibraries.find(lib => lib.id === id); - if (foundLib) { - this.selections.toggle(foundLib, true, (a, b) => a.name === b.name); - } - }); - this.selectAll = this.selections.selected().length === this.allLibraries.length; - } - this.cdRef.markForCheck(); - } - - toggleAll() { - this.selectAll = !this.selectAll; - this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll)); - this.selected.emit(this.selections.selected()); - this.cdRef.markForCheck(); - } - - handleSelection(item: Library) { - this.selections.toggle(item); - const numberOfSelected = this.selections.selected().length; - if (numberOfSelected == 0) { - this.selectAll = false; - } else if (numberOfSelected == this.selectedLibraries.length) { - this.selectAll = true; - } - - this.cdRef.markForCheck(); - this.selected.emit(this.selections.selected()); - } - -} diff --git a/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.html b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.html index e52b33434..18931c370 100644 --- a/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.html +++ b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.html @@ -2,47 +2,21 @@
- @if(settingsForm().get('blacklist'); as formControl) { - - - - @let val = breakTags(formControl.value); - @for(opt of val; track opt) { - {{opt.trim()}} - } @empty { - {{null | defaultValue}} - } - - - - - - - - } +
- @if(settingsForm().get('whitelist'); as formControl) { - - - @let val = breakTags(formControl.value); - - @for(opt of val; track opt) { - {{opt.trim()}} - } @empty { - {{null | defaultValue}} - } - s - - - - - } +
diff --git a/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts index 20a428357..bd43c6cd1 100644 --- a/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts +++ b/UI/Web/src/app/admin/manage-metadata-mappings/manage-metadata-mappings.component.ts @@ -1,15 +1,15 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, OnInit, signal} from '@angular/core'; import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; -import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; -import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; -import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; import {MetadataFieldMapping, MetadataFieldType, MetadataSettings} from "../_models/metadata-settings"; import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; import {MetadataService} from "../../_services/metadata.service"; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {AgeRating} from "../../_models/metadata/age-rating"; import {DownloadService} from "../../shared/_services/download.service"; +import { + SettingMultiTextFieldComponent +} from "../../settings/_components/setting-multi-text-field/setting-multi-text-field.component"; export type MetadataMappingsExport = { ageRatingMappings: Record, @@ -22,12 +22,10 @@ export type MetadataMappingsExport = { selector: 'app-manage-metadata-mappings', imports: [ AgeRatingPipe, - DefaultValuePipe, FormsModule, ReactiveFormsModule, - SettingItemComponent, - TagBadgeComponent, TranslocoDirective, + SettingMultiTextFieldComponent, ], templateUrl: './manage-metadata-mappings.component.html', styleUrl: './manage-metadata-mappings.component.scss', @@ -74,8 +72,8 @@ export class ManageMetadataMappingsComponent implements OnInit { const settings = this.settings(); const settingsForm = this.settingsForm(); - settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), [])); - settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), [])); + settingsForm.addControl('blacklist', new FormControl(settings.blacklist, [])); + settingsForm.addControl('whitelist', new FormControl(settings.whitelist, [])); settingsForm.addControl('ageRatingMappings', this.ageRatingMappings); settingsForm.addControl('fieldMappings', this.fieldMappings); @@ -94,14 +92,6 @@ export class ManageMetadataMappingsComponent implements OnInit { this.cdRef.markForCheck(); } - breakTags(csString: string) { - if (csString) { - return csString.split(','); - } - - return []; - } - public packData(): MetadataMappingsExport { const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc: Record, control) => { const { str, rating } = control.value; @@ -114,15 +104,11 @@ export class ManageMetadataMappingsComponent implements OnInit { const fieldMappings = this.fieldMappings.controls .map((control) => control.value as MetadataFieldMapping) .filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0); - - const blacklist = (this.settingsForm().get('blacklist')?.value || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0); - const whitelist = (this.settingsForm().get('whitelist')?.value || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0); - return { ageRatingMappings: ageRatingMappings, fieldMappings: fieldMappings, - blacklist: blacklist, - whitelist: whitelist, + blacklist: this.settingsForm().get('blacklist')?.value || [], + whitelist: this.settingsForm().get('whitelist')?.value || [], } } diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html index 6a7d16c42..335dd8a13 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html @@ -4,275 +4,274 @@
-
- - -

{{t('provider-title')}}

-
- -
- @if (settingsForm.get('authority'); as formControl) { - - - {{formControl.value}} - - - - - @if (settingsForm.dirty || !settingsForm.untouched) { -
- @if (formControl.errors?.invalidUri) { -
{{t('invalid-uri')}}
- } -
- } -
-
- } + @if (!loading()) { + + -
- @if (settingsForm.get('clientId'); as formControl) { - - - {{formControl.value}} - - - +

{{t('provider-title')}}

+
+ +
+ @if (settingsForm.get('authority'); as formControl) { + + + {{formControl.value}} + + + - @if (settingsForm.dirty || !settingsForm.untouched) { -
- @if (formControl.errors && formControl.errors.requiredIf) { -
{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}
- } -
- } - -
-
- } -
- -
- @if (settingsForm.get('secret'); as formControl) { - - - {{formControl.value | defaultValue}} - - - - - @if (settingsForm.dirty || !settingsForm.untouched) { -
- @if (formControl.errors && formControl.errors.requiredIf) { -
{{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}}
- } -
- } - -
-
- } -
- -
- -
-

{{t('behavior-title')}}

- -
- @if (settingsForm.get('providerName'); as formControl) { - - - {{formControl.value}} - - - - - - } -
- -
- @if(settingsForm.get('provisionAccounts'); as formControl) { - - -
- -
-
-
- } -
- -
- @if(settingsForm.get('requireVerifiedEmail'); as formControl) { - - -
- -
-
-
- } -
- -
- @if(settingsForm.get('syncUserSettings'); as formControl) { - - -
- -
-
-
- } -
- -
- @if(settingsForm.get('autoLogin'); as formControl) { - - -
- -
-
-
- } -
- -
- @if(settingsForm.get('disablePasswordAuthentication'); as formControl) { - - -
- -
-
-
- } -
- -
- -
-

{{t('defaults-title')}}

-
{{t('defaults-requirement')}}
- - -
- @if(settingsForm.get('defaultAgeRestriction'); as formControl) { - - -
{{formControl.value | ageRating}}
-
- - - -
- } -
- -
- @if(settingsForm.get('defaultIncludeUnknowns'); as formControl) { - - -
- -
-
-
- } -
- - @if (oidcSettings()) { -
-
- -
- -
- -
+ + + }
- } -
+
+ @if (settingsForm.get('clientId'); as formControl) { + + + {{formControl.value}} + + + -
-

{{t('advanced-title')}}

-
{{t('advanced-tooltip')}}
+ @if (settingsForm.dirty || !settingsForm.untouched) { +
+ @if (formControl.errors && formControl.errors.requiredIf) { +
{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}
+ } +
+ } - -
- @if (settingsForm.get('rolesPrefix'); as formControl) { - - - {{formControl.value}} - - - - - + + + } +
+ +
+ @if (settingsForm.get('secret'); as formControl) { + + + {{formControl.value | defaultValue}} + + + + + @if (settingsForm.dirty || !settingsForm.untouched) { +
+ @if (formControl.errors && formControl.errors.requiredIf) { +
{{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}}
+ } +
+ } + +
+
+ } +
+ +
+ +
+

{{t('behavior-title')}}

+ +
+ @if (settingsForm.get('providerName'); as formControl) { + + + {{formControl.value}} + + + + + + } +
+ +
+ @if(settingsForm.get('provisionAccounts'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('requireVerifiedEmail'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('syncUserSettings'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('autoLogin'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ @if(settingsForm.get('disablePasswordAuthentication'); as formControl) { + + +
+ +
+
+
+ } +
+ +
+ +
+

{{t('defaults-title')}}

+
{{t('defaults-requirement')}}
+ + +
+ @if(settingsForm.get('defaultAgeRestriction'); as formControl) { + + +
{{formControl.value | ageRating}}
+
+ + + +
+ } +
+ +
+ @if(settingsForm.get('defaultIncludeUnknowns'); as formControl) { + + +
+ +
+
+
+ } +
+ + @if (oidcSettings()) { +
+
+ +
+ +
+ +
+
} -
-
- @if (settingsForm.get('rolesClaim'); as formControl) { - - - {{formControl.value}} - - - - - - } -
+ -
- @if (settingsForm.get('customScopes'); as formControl) { - - - @let val = breakString(formControl.value); +
+

{{t('advanced-title')}}

+
{{t('advanced-tooltip')}}
- @for(opt of val; track opt) { - {{opt.trim()}} - } @empty { - {{null | defaultValue}} - } -
+ +
+ @if (settingsForm.get('rolesPrefix'); as formControl) { + + + {{formControl.value | defaultValue}} + + + + + + } +
- - - +
+ @if (settingsForm.get('rolesClaim'); as formControl) { + + + {{formControl.value}} + + + + + + } +
-
+
+ +
- } -
+ - - - + + } diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts index d404839f3..d50d40455 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts @@ -2,8 +2,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, DestroyRef, - effect, inject, OnInit, signal @@ -15,6 +15,7 @@ import { AsyncValidatorFn, FormControl, FormGroup, + NonNullableFormBuilder, ReactiveFormsModule, ValidationErrors, ValidatorFn @@ -23,22 +24,44 @@ import {SettingsService} from "../settings.service"; import {OidcConfig} from "../_models/oidc-config"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; -import {debounceTime, distinctUntilChanged, filter, map, of, switchMap, tap} from "rxjs"; +import {debounceTime, distinctUntilChanged, filter, forkJoin, map, of, tap} from "rxjs"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {RestrictionSelectorComponent} from "../../user-settings/restriction-selector/restriction-selector.component"; import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; import {MetadataService} from "../../_services/metadata.service"; import {AgeRating} from "../../_models/metadata/age-rating"; import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; -import {allRoles, Role} from "../../_services/account.service"; +import {AccountService, allRoles, Role} from "../../_services/account.service"; import {Library} from "../../_models/library/library"; import {LibraryService} from "../../_services/library.service"; -import {LibrarySelectorComponent} from "../library-selector/library-selector.component"; -import {RoleSelectorComponent} from "../role-selector/role-selector.component"; import {ToastrService} from "ngx-toastr"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; -import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; +import { + MultiCheckBoxItem, + SettingMultiCheckBox +} from "../../settings/_components/setting-multi-check-box/setting-multi-check-box.component"; +import { + SettingMultiTextFieldComponent +} from "../../settings/_components/setting-multi-text-field/setting-multi-text-field.component"; + +type OidcFormGroup = FormGroup<{ + autoLogin: FormControl; + disablePasswordAuthentication: FormControl; + providerName: FormControl; + authority: FormControl; + clientId: FormControl; + secret: FormControl; + provisionAccounts: FormControl; + requireVerifiedEmail: FormControl; + syncUserSettings: FormControl; + rolesPrefix: FormControl; + rolesClaim: FormControl; + customScopes: FormControl; + defaultRoles: FormControl; + defaultLibraries: FormControl; + defaultAgeRestriction: FormControl; + defaultIncludeUnknowns: FormControl; +}>; @Component({ selector: 'app-manage-open-idconnect', @@ -48,11 +71,10 @@ import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; SettingItemComponent, SettingSwitchComponent, AgeRatingPipe, - LibrarySelectorComponent, - RoleSelectorComponent, SafeHtmlPipe, DefaultValuePipe, - TagBadgeComponent + SettingMultiCheckBox, + SettingMultiTextFieldComponent ], templateUrl: './manage-open-idconnect.component.html', styleUrl: './manage-open-idconnect.component.scss', @@ -65,81 +87,88 @@ export class ManageOpenIDConnectComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly metadataService = inject(MetadataService); private readonly toastr = inject(ToastrService); + private readonly fb = inject(NonNullableFormBuilder); + private readonly accountService = inject(AccountService); + private readonly libraryService = inject(LibraryService); serverSettings!: ServerSettings; - settingsForm: FormGroup = new FormGroup({}); + settingsForm!: OidcFormGroup; + loading = signal(true); oidcSettings = signal(undefined); ageRatings = signal([]); - selectedLibraries = signal([]); - selectedRoles = signal([]); + libraries = signal([]); + libraryOptions = computed(() => this.libraries().map(l => { + return { label: l.name, value: l.id }; + })); + roles = signal(allRoles); + roleOptions: MultiCheckBoxItem[] = allRoles.map(r => { + return { label: r, value: r, disableFunc: (r, selected) => { + return r !== Role.Admin && selected.includes(Role.Admin); + }} + }); ngOnInit(): void { - this.metadataService.getAllAgeRatings().subscribe(ratings => { - this.ageRatings.set(ratings); - }); + forkJoin([ + this.metadataService.getAllAgeRatings(), + this.settingsService.getServerSettings(), + this.libraryService.getLibraries(), + ]).subscribe(([ageRatings, settings, libraries]) => { + this.ageRatings.set(ageRatings); + this.libraries.set(libraries); - this.settingsService.getServerSettings().subscribe({ - next: data => { - this.serverSettings = data; - this.oidcSettings.set(this.serverSettings.oidcConfig); - this.selectedRoles.set(this.serverSettings.oidcConfig.defaultRoles); - this.selectedLibraries.set(this.serverSettings.oidcConfig.defaultLibraries); + this.serverSettings = settings; + this.oidcSettings.set(this.serverSettings.oidcConfig); - this.settingsForm.addControl('authority', new FormControl(this.serverSettings.oidcConfig.authority, [], [this.authorityValidator()])); - this.settingsForm.addControl('clientId', new FormControl(this.serverSettings.oidcConfig.clientId, [this.requiredIf('authority')])); - this.settingsForm.addControl('secret', new FormControl(this.serverSettings.oidcConfig.secret, [this.requiredIf('authority')])); - this.settingsForm.addControl('provisionAccounts', new FormControl(this.serverSettings.oidcConfig.provisionAccounts, [])); - this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.serverSettings.oidcConfig.requireVerifiedEmail, [])); - this.settingsForm.addControl('syncUserSettings', new FormControl(this.serverSettings.oidcConfig.syncUserSettings, [])); - this.settingsForm.addControl('rolesPrefix', new FormControl(this.serverSettings.oidcConfig.rolesPrefix, [])); - this.settingsForm.addControl('rolesClaim', new FormControl(this.serverSettings.oidcConfig.rolesClaim, [])); - this.settingsForm.addControl('autoLogin', new FormControl(this.serverSettings.oidcConfig.autoLogin, [])); - this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.serverSettings.oidcConfig.disablePasswordAuthentication, [])); - this.settingsForm.addControl('providerName', new FormControl(this.serverSettings.oidcConfig.providerName, [])); - this.settingsForm.addControl("defaultAgeRestriction", new FormControl(this.serverSettings.oidcConfig.defaultAgeRestriction, [])); - this.settingsForm.addControl('defaultIncludeUnknowns', new FormControl(this.serverSettings.oidcConfig.defaultIncludeUnknowns, [])); - this.settingsForm.addControl('customScopes', new FormControl(this.serverSettings.oidcConfig.customScopes.join(","), [])) - this.cdRef.markForCheck(); + this.settingsForm = this.fb.group({ + authority: this.fb.control(this.serverSettings.oidcConfig.authority, { asyncValidators: [this.authorityValidator()] }), + clientId: this.fb.control(this.serverSettings.oidcConfig.clientId, { validators: [this.requiredIf('authority')] }), + secret: this.fb.control(this.serverSettings.oidcConfig.secret, { validators: [this.requiredIf('authority')] }), + provisionAccounts: this.fb.control(this.serverSettings.oidcConfig.provisionAccounts), + requireVerifiedEmail: this.fb.control(this.serverSettings.oidcConfig.requireVerifiedEmail), + syncUserSettings: this.fb.control(this.serverSettings.oidcConfig.syncUserSettings), + rolesPrefix: this.fb.control(this.serverSettings.oidcConfig.rolesPrefix), + rolesClaim: this.fb.control(this.serverSettings.oidcConfig.rolesClaim), + autoLogin: this.fb.control(this.serverSettings.oidcConfig.autoLogin), + disablePasswordAuthentication: this.fb.control(this.serverSettings.oidcConfig.disablePasswordAuthentication), + providerName: this.fb.control(this.serverSettings.oidcConfig.providerName), + defaultLibraries: this.fb.control(this.serverSettings.oidcConfig.defaultLibraries), + defaultRoles: this.fb.control(this.serverSettings.oidcConfig.defaultRoles), + defaultAgeRestriction: this.fb.control(this.serverSettings.oidcConfig.defaultAgeRestriction), + defaultIncludeUnknowns: this.fb.control(this.serverSettings.oidcConfig.defaultIncludeUnknowns), + customScopes: this.fb.control(this.serverSettings.oidcConfig.customScopes) + }); - this.settingsForm.valueChanges.pipe( - debounceTime(300), - distinctUntilChanged(), - takeUntilDestroyed(this.destroyRef), - filter(() => { - // Do not auto save when provider settings have changed - const settings: OidcConfig = this.settingsForm.getRawValue(); - return settings.authority == this.oidcSettings()?.authority && settings.clientId == this.oidcSettings()?.clientId; - }), - tap(() => this.save()) - ).subscribe(); - } - }); + this.loading.set(false); + this.cdRef.markForCheck(); + + this.settingsForm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef), + filter(() => { + // Do not auto save when provider settings have changed + const settings: OidcConfig = this.packData().oidcConfig; + return settings.authority == this.oidcSettings()?.authority && settings.clientId == this.oidcSettings()?.clientId; + }), + tap(() => this.save()) + ).subscribe(); + }) } - updateRoles(roles: string[]) { - this.selectedRoles.set(roles); - this.save(); - } - - updateLibraries(libraries: Library[]) { - this.selectedLibraries.set(libraries.map(l => l.id)); - this.save(); + private packData(): ServerSettings { + const newSettings = Object.assign({}, this.serverSettings); + newSettings.oidcConfig = { + ...this.settingsForm.getRawValue(), + enabled: false, + }; + return newSettings; } save(showConfirmation: boolean = false) { - if (!this.settingsForm.valid || !this.serverSettings || !this.oidcSettings) return; - - const data = this.settingsForm.getRawValue(); - const newSettings = Object.assign({}, this.serverSettings); - newSettings.oidcConfig = data as OidcConfig; - newSettings.oidcConfig.defaultAgeRestriction = parseInt(newSettings.oidcConfig.defaultAgeRestriction + '', 10) as AgeRating; - newSettings.oidcConfig.defaultRoles = this.selectedRoles(); - newSettings.oidcConfig.defaultLibraries = this.selectedLibraries(); - newSettings.oidcConfig.customScopes = (data.customScopes as string) - .split(',').map((item: string) => item.trim()) - .filter((scope: string) => scope.length > 0); + if (!this.settingsForm.valid || !this.serverSettings || !this.oidcSettings()) return; + const newSettings = this.packData(); this.settingsService.updateServerSettings(newSettings).subscribe({ next: data => { this.serverSettings = data; @@ -157,14 +186,6 @@ export class ManageOpenIDConnectComponent implements OnInit { }) } - breakString(s: string) { - if (s) { - return s.split(','); - } - - return []; - } - authorityValidator(): AsyncValidatorFn { return (control: AbstractControl) => { let uri: string = control.value; @@ -188,6 +209,8 @@ export class ManageOpenIDConnectComponent implements OnInit { requiredIf(other: string): ValidatorFn { return (control): ValidationErrors | null => { + if (!this.settingsForm) return null; + const otherControl = this.settingsForm.get(other); if (!otherControl) return null; diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.html b/UI/Web/src/app/admin/role-selector/role-selector.component.html deleted file mode 100644 index 7d3eca0c2..000000000 --- a/UI/Web/src/app/admin/role-selector/role-selector.component.html +++ /dev/null @@ -1,28 +0,0 @@ - -
-
-

{{t('title')}}

-
-
- @if(selectedRoles.length > 0) { - - - - - } -
-
- -
    - @for(role of selectedRoles; track role; let i = $index) { -
  • -
    - - -
    -
  • - } -
-
diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.scss b/UI/Web/src/app/admin/role-selector/role-selector.component.scss deleted file mode 100644 index 3f2adc8d1..000000000 --- a/UI/Web/src/app/admin/role-selector/role-selector.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.list-group-item { - border: none; -} \ No newline at end of file diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.ts b/UI/Web/src/app/admin/role-selector/role-selector.component.ts deleted file mode 100644 index ab9bbb6f5..000000000 --- a/UI/Web/src/app/admin/role-selector/role-selector.component.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - inject, input, - Input, - OnInit, - Output -} from '@angular/core'; -import {Member} from 'src/app/_models/auth/member'; -import {User} from 'src/app/_models/user'; -import {AccountService} from 'src/app/_services/account.service'; -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {TranslocoDirective,} from "@jsverse/transloco"; -import {SelectionModel} from "../../typeahead/_models/selection-model"; -import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe"; - -@Component({ - selector: 'app-role-selector', - templateUrl: './role-selector.component.html', - styleUrls: ['./role-selector.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule, FormsModule, TranslocoDirective, RoleLocalizedPipe] -}) -export class RoleSelectorComponent implements OnInit { - - private readonly accountService = inject(AccountService); - private readonly cdRef = inject(ChangeDetectorRef); - - - /** - * This must have roles - */ - @Input() member: Member | undefined | User; - preSelectedRoles = input([]); - /** - * Allows the selection of Admin role - */ - @Input() allowAdmin: boolean = false; - @Output() selected: EventEmitter = new EventEmitter(); - - allRoles: string[] = []; - selectedRoles: Array<{selected: boolean, disabled: boolean, data: string}> = []; - selections!: SelectionModel; - selectAll: boolean = false; - - get hasSomeSelected() { - return this.selections != null && this.selections.hasSomeSelected(); - } - - ngOnInit(): void { - this.accountService.getRoles().subscribe(roles => { - const bannedRoles = ['Pleb']; - if (!this.allowAdmin) { - bannedRoles.push('Admin'); - } - roles = roles.filter(item => !bannedRoles.includes(item)); - this.allRoles = roles; - this.selections = new SelectionModel(false, this.allRoles); - - this.selectedRoles = roles.map(item => { - return {selected: false, disabled: false, data: item}; - }); - - this.cdRef.markForCheck(); - this.preselect(); - - this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data)); - }); - } - - preselect() { - if (this.member !== undefined) { - this.member.roles.forEach(role => { - const foundRole = this.selectedRoles.filter(item => item.data === role); - if (foundRole.length > 0) { - foundRole[0].selected = true; - } - }); - } else if (this.preSelectedRoles().length > 0) { - this.preSelectedRoles().forEach((role) => { - const foundRole = this.selectedRoles.filter(item => item.data === role); - if (foundRole.length > 0) { - foundRole[0].selected = true; - } - }); - } else { - // For new users, preselect LoginRole - this.selectedRoles.forEach(role => { - if (role.data == 'Login') { - role.selected = true; - } - }); - } - this.syncSelections(); - this.cdRef.markForCheck(); - } - - handleModelUpdate() { - const roles = this.selectedRoles.filter(item => item.selected).map(item => item.data); - if (roles.filter(r => r === 'Admin').length > 0) { - // Disable all other items as Admin is selected - this.selectedRoles.filter(item => item.data !== 'Admin').forEach(e => { - e.disabled = true; - }); - } else { - // Re-enable everything - this.selectedRoles.forEach(e => { - e.disabled = false; - }); - } - this.syncSelections(); - this.cdRef.markForCheck(); - this.selected.emit(roles); - } - - syncSelections() { - this.selectedRoles.forEach(s => this.selections.toggle(s.data, s.selected)); - this.cdRef.markForCheck(); - } - - toggleAll() { - this.selectAll = !this.selectAll; - - // Update selectedRoles considering disabled state - this.selectedRoles.filter(r => !r.disabled).forEach(r => r.selected = this.selectAll); - - // Sync selections with updated selectedRoles - this.syncSelections(); - - this.selected.emit(this.selections.selected()); - this.cdRef.markForCheck(); - } - -} diff --git a/UI/Web/src/app/all-annotations/all-annotations.component.html b/UI/Web/src/app/all-annotations/all-annotations.component.html index b2ffc4b20..7f405c360 100644 --- a/UI/Web/src/app/all-annotations/all-annotations.component.html +++ b/UI/Web/src/app/all-annotations/all-annotations.component.html @@ -41,6 +41,8 @@ [showPageLink]="false" [openInIncognitoMode]="true" [showSelectionBox]="true" + [showLocationInformation]="true" + [listedToUpdates]="true" [selected]="bulkSelectionService.isCardSelected('annotations', position)" (selection)="bulkSelectionService.handleCardSelection('annotations', position, annotations().length, $event)" /> diff --git a/UI/Web/src/app/all-annotations/all-annotations.component.ts b/UI/Web/src/app/all-annotations/all-annotations.component.ts index b06bda5e3..83204ec3e 100644 --- a/UI/Web/src/app/all-annotations/all-annotations.component.ts +++ b/UI/Web/src/app/all-annotations/all-annotations.component.ts @@ -37,6 +37,7 @@ import {Action, ActionFactoryService, ActionItem} from "../_services/action-fact import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component"; import {BulkSelectionService} from "../cards/bulk-selection.service"; import {User} from "../_models/user"; +import {AccountService} from "../_services/account.service"; @Component({ selector: 'app-all-annotations', @@ -62,6 +63,7 @@ export class AllAnnotationsComponent implements OnInit { private readonly metadataService = inject(MetadataService); private readonly actionFactoryService = inject(ActionFactoryService); public readonly bulkSelectionService = inject(BulkSelectionService); + private readonly accountService = inject(AccountService); isLoading = signal(true); annotations = signal([]); @@ -118,6 +120,7 @@ export class AllAnnotationsComponent implements OnInit { } handleAction = async (action: ActionItem, entity: Annotation) => { + const userId = this.accountService.currentUserSignal()!.id; const selectedIndices = this.bulkSelectionService.getSelectedCardsForSource('annotations'); const selectedAnnotations = this.annotations().filter((_, idx) => selectedIndices.includes(idx+'')); const ids = selectedAnnotations.map(a => a.id); @@ -142,9 +145,36 @@ export class AllAnnotationsComponent implements OnInit { case Action.Export: this.annotationsService.exportAnnotations(ids).subscribe(); break + case Action.Like: + this.annotationsService.likeAnnotations(ids).pipe( + tap(() => this.updateLikes(ids, userId, true)), + ).subscribe(); + break; + case Action.UnLike: + this.annotationsService.unLikeAnnotations(ids).pipe( + tap(() => this.updateLikes(ids, userId, false)), + ).subscribe(); } } + private updateLikes(ids: number[], userId: number, like: boolean): void { + this.annotations.update(annotations => + annotations.map(annotation => { + if (!ids.includes(annotation.id)) return annotation; + + let likes; + if (like) { + likes = annotation.likes.includes(userId) ? annotation.likes : [...annotation.likes, userId]; + } else { + likes = annotation.likes.filter(id => id !== userId); + } + + return { ...annotation, likes }; + }) + ); + } + + exportFilter() { const filter = this.filter(); if (!filter) return; diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html index 180dc0921..2b74ccb4c 100644 --- a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.html @@ -1,7 +1,11 @@
+
+ @if (showLocationInformation()) { + + } {{ annotation().ownerUsername }}
{{ annotation().createdUtc | utcToLocaleDate | date: 'shortDate' }}
@@ -53,18 +57,28 @@ }
- @if(annotation().containsSpoiler) { -
+
+ @if(annotation().containsSpoiler) { +
{{t('contains-spoilers-label')}} -
- } +
+ } + + + + @if (showSelectionBox()) { + + } + +
- @if (showSelectionBox()) { - - }
diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts index c27d8a69a..7ebe216a0 100644 --- a/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-card/annotation-card.component.ts @@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, computed, + DestroyRef, + effect, EventEmitter, inject, input, @@ -21,6 +23,11 @@ import {DefaultValuePipe} from "../../../../_pipes/default-value.pipe"; import {SlotColorPipe} from "../../../../_pipes/slot-color.pipe"; import {ColorscapeService} from "../../../../_services/colorscape.service"; import {ActivatedRoute, Router, RouterLink} from "@angular/router"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {AccountService} from "../../../../_services/account.service"; +import {EVENTS, MessageHubService} from "../../../../_services/message-hub.service"; +import {AnnotationUpdateEvent} from "../../../../_models/events/annotation-update-event"; +import {AnnotationLikesComponent} from "../annotation-likes/annotation-likes.component"; @Component({ selector: 'app-annotation-card', @@ -32,7 +39,9 @@ import {ActivatedRoute, Router, RouterLink} from "@angular/router"; DefaultValuePipe, NgStyle, RouterLink, - NgClass + NgClass, + NgbTooltip, + AnnotationLikesComponent ], templateUrl: './annotation-card.component.html', styleUrl: './annotation-card.component.scss', @@ -47,6 +56,9 @@ export class AnnotationCardComponent { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly highlightSlotPipe = new SlotColorPipe(); + protected readonly accountService = inject(AccountService); + private readonly messageHub = inject(MessageHubService); + private readonly destroyRef = inject(DestroyRef); annotation = model.required(); allowEdit = input(true); @@ -59,13 +71,31 @@ export class AnnotationCardComponent { * Redirects to the reader with annotation in view */ showInReaderLink = input(false); + /** + * Disable a selection checkbox. Fires selection when called + */ showSelectionBox = input(false); + /** + * Displays series and library name + */ + showLocationInformation = input(false); + /** + * Disable a like button + */ + showLikes = input(true); openInIncognitoMode = input(false); isInReader = input(true); + /** + * If enabled, listens to annotation updates + */ + listedToUpdates = input(false); selected = input(false); @Output() delete = new EventEmitter(); @Output() navigate = new EventEmitter(); + /** + * Fire when the checkbox is pressed, with the last known state (inverse of checked state) + */ @Output() selection = new EventEmitter(); titleColor: Signal; @@ -73,16 +103,16 @@ export class AnnotationCardComponent { constructor() { - // TODO: Validate if I want this -- aka update content on a detail page when receiving update from backend - // this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => { - // if (message.payload !== EVENTS.AnnotationUpdate) return; - // const updatedAnnotation = message.payload as AnnotationUpdateEvent; - // if (this.annotation()?.id !== updatedAnnotation.annotation.id) return; - // - // console.log('Refreshing annotation from backend: ', updatedAnnotation.annotation); - // this.annotation.set(updatedAnnotation.annotation); - // }); + effect(() => { + const enabled = this.listedToUpdates(); + const event = this.messageHub.messageSignal(); + if (!enabled || event?.event !== EVENTS.AnnotationUpdate) return; + const newAnnotation = (event.payload as AnnotationUpdateEvent).annotation; + if (this.annotation().id != newAnnotation.id) return; + + this.annotation.set(newAnnotation); + }); this.titleColor = computed(() => { const annotation = this.annotation(); @@ -134,6 +164,5 @@ export class AnnotationCardComponent { this.annotationService.delete(annotation.id).subscribe(_ => { this.delete.emit(); }); - } } diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.html b/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.html new file mode 100644 index 000000000..d7253e4bc --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.html @@ -0,0 +1,13 @@ + + @if (visible()) { + + } + diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.scss b/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.ts b/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.ts new file mode 100644 index 000000000..e25d556e3 --- /dev/null +++ b/UI/Web/src/app/book-reader/_components/_annotations/annotation-likes/annotation-likes.component.ts @@ -0,0 +1,60 @@ +import {ChangeDetectionStrategy, Component, computed, inject, input, model} from '@angular/core'; +import {Annotation} from "../../../_models/annotations/annotation"; +import {AnnotationService} from "../../../../_services/annotation.service"; +import {AccountService} from "../../../../_services/account.service"; +import {tap} from "rxjs/operators"; +import {TranslocoDirective} from "@jsverse/transloco"; + +@Component({ + selector: 'app-annotation-likes', + imports: [ + TranslocoDirective + ], + templateUrl: './annotation-likes.component.html', + styleUrl: './annotation-likes.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AnnotationLikesComponent { + + private readonly annotationService = inject(AnnotationService); + protected readonly accountService = inject(AccountService) + + /** + * If the element should be shown + */ + visible = input.required(); + + /** + * The annotation for which the likes are shown. Will emit a annotationChange when the likes update + */ + annotation = model.required(); + + liked = computed(() => this.annotation().likes.includes(this.accountService.userId() ?? 0)); + + handleLikeChange() { + const userId = this.accountService.userId(); + if (!userId) return; + + if (this.annotation().ownerUserId ===userId) return; + + const sub$ = this.liked() + ? this.annotationService.unLikeAnnotations([this.annotation().id]) + : this.annotationService.likeAnnotations([this.annotation().id]); + + sub$.pipe( + tap(() => { + this.annotation.update(x => { + const newLikes = this.liked() + ? x.likes.filter(id => id !== userId) + : [...x.likes, userId]; + + return { + ...x, + likes: newLikes, + } + }) + }) + ).subscribe(); + } + +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html index a2d72d967..3a05cfbd5 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.html @@ -30,7 +30,13 @@ } @for(annotation of annotations() | filter: filterList; track annotation.comment + annotation.highlightColor + annotation.containsSpolier) { - + } @empty {

{{t('no-data')}}

diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.ts index 35cb540d8..396f190c4 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.ts +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component.ts @@ -10,6 +10,7 @@ import { OffCanvasResizeComponent, ResizeMode } from "../../../../shared/_components/off-canvas-resize/off-canvas-resize.component"; +import {AccountService} from "../../../../_services/account.service"; @Component({ selector: 'app-view-annotations-drawer', @@ -28,6 +29,7 @@ export class ViewAnnotationsDrawerComponent { private readonly activeOffcanvas = inject(NgbActiveOffcanvas); private readonly annotationService = inject(AnnotationService); + protected readonly accountService = inject(AccountService); @Output() loadAnnotation: EventEmitter = new EventEmitter(); diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html index 090abb454..d064d9505 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.html @@ -55,6 +55,13 @@ @case (AnnotationMode.View) { @let an = annotation(); @if (an) { + + +
{{ an.ownerUsername }} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.scss index 4573511c7..eb96a89e7 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.scss +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.scss @@ -45,9 +45,4 @@ $green-color: rgba(34, 197, 94); background-color: $green-color; } -::ng-deep .ql-editor { - height: calc(var(--drawer-height) - 278px); - overflow: auto; -} - diff --git a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts index 39e563f62..0e3c5af0b 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts +++ b/UI/Web/src/app/book-reader/_components/_drawers/view-edit-annotation-drawer/view-edit-annotation-drawer.component.ts @@ -17,7 +17,7 @@ import {FormControl, FormGroup, NonNullableFormBuilder, ReactiveFormsModule} fro import {Annotation} from "../../../_models/annotations/annotation"; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {debounceTime, switchMap} from "rxjs/operators"; +import {debounceTime, switchMap, tap} from "rxjs/operators"; import {of} from "rxjs"; import {HighlightBarComponent} from "../../_annotations/highlight-bar/highlight-bar.component"; import {SlotColorPipe} from "../../../../_pipes/slot-color.pipe"; @@ -37,6 +37,7 @@ import { ResizeMode } from "../../../../shared/_components/off-canvas-resize/off-canvas-resize.component"; import {ConfirmService} from "../../../../shared/confirm.service"; +import {AnnotationLikesComponent} from "../../_annotations/annotation-likes/annotation-likes.component"; export enum AnnotationMode { View = 0, @@ -59,7 +60,8 @@ const INIT_HIGHLIGHT_DELAY = 200; QuillViewComponent, DatePipe, UtcToLocaleDatePipe, - OffCanvasResizeComponent + OffCanvasResizeComponent, + AnnotationLikesComponent ], templateUrl: './view-edit-annotation-drawer.component.html', styleUrl: './view-edit-annotation-drawer.component.scss', @@ -91,7 +93,6 @@ export class ViewEditAnnotationDrawerComponent implements OnInit { titleColor: Signal; totalText!: Signal; - formGroup!: FormGroup<{ note: FormControl, hasSpoiler: FormControl, @@ -269,12 +270,8 @@ export class ViewEditAnnotationDrawerComponent implements OnInit { const annotation = this.annotation(); if (annotation) { - console.log('view-edit drawer, slot index changed: ', slotIndex, 'comment: ', this.annotation()?.comment, 'form comment: ', this.formGroup.get('note')?.value); this.annotation.set({...annotation, selectedSlotIndex: slotIndex}); this.formGroup.get('selectedSlotIndex')?.setValue(slotIndex); - - // Patch back in any text in the quill editor - console.log('(2) view-edit drawer, slot index changed: ', slotIndex, 'comment: ', this.annotation()?.comment, 'form comment: ', this.formGroup.get('note')?.value); } } diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts index 92522c087..4eb02a73e 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -14,7 +14,6 @@ import { } from '@angular/core'; import {fromEvent, merge, of} from "rxjs"; import {catchError, debounceTime, tap} from "rxjs/operators"; -import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {ReaderService} from "../../../_services/reader.service"; import {ToastrService} from "ngx-toastr"; @@ -22,6 +21,8 @@ import {translate, TranslocoDirective} from "@jsverse/transloco"; import {KEY_CODES} from "../../../shared/_services/utility.service"; import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service"; import {Annotation} from "../../_models/annotations/annotation"; +import {isMobileChromium} from "../../../_helpers/browser"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; enum BookLineOverlayMode { None = 0, @@ -78,73 +79,134 @@ export class BookLineOverlayComponent implements OnInit { ngOnInit() { - if (this.parent) { + // Check for Pointer Events API support + const hasPointerEvents = 'PointerEvent' in window; - const mouseUp$ = fromEvent(this.parent.nativeElement, 'mouseup'); - const touchEnd$ = fromEvent(this.parent.nativeElement, 'touchend'); - - merge(mouseUp$, touchEnd$) - .pipe( - takeUntilDestroyed(this.destroyRef), - debounceTime(20), // Need extra time for this extension to inject DOM https://github.com/Kareadita/Kavita/issues/3521 - tap((event: MouseEvent | TouchEvent) => this.handleEvent(event)) - ).subscribe(); + // Some mobile Chromium browsers do not send touchend events reliably: https://github.com/Kareadita/Kavita/issues/4072 + if (hasPointerEvents && !isMobileChromium()) { + // Use pointer events for modern browsers (except problematic mobile Chromium) + this.setupPointerEventListener(); + } else { + // Fallback to mouse/touch events + this.setupLegacyEventListeners(); } } - handleEvent(event: MouseEvent | TouchEvent) { - const selection = window.getSelection(); + private setupPointerEventListener(): void { + if (!this.parent) return; + + fromEvent(this.parent.nativeElement, 'pointerup') + .pipe( + takeUntilDestroyed(this.destroyRef), + debounceTime(20), + tap((event: PointerEvent) => this.handlePointerEvent(event)) + ).subscribe(); + } + + private setupLegacyEventListeners(): void { + if (!this.parent) return; + + const mouseUp$ = fromEvent(this.parent.nativeElement, 'mouseup'); + const touchEnd$ = fromEvent(this.parent.nativeElement, 'touchend'); + + // Additional events for mobile Chromium workaround + const additionalEvents$ = isMobileChromium() ? [ + fromEvent(this.parent.nativeElement, 'touchcancel'), + fromEvent(this.parent.nativeElement, 'pointerup') + ] : []; + + merge(mouseUp$, touchEnd$, ...additionalEvents$) + .pipe( + takeUntilDestroyed(this.destroyRef), + debounceTime(20), + tap((event: MouseEvent | TouchEvent | PointerEvent) => this.handleLegacyEvent(event)) + ).subscribe(); + } + + private handlePointerEvent(event: PointerEvent): void { + // Filter out pen/stylus events if you don't want to handle them + if (event.pointerType === 'pen') { + return; + } + + // Check for right-click + const isRightClick = event.button === 2; + + this.processSelectionEvent(event, isRightClick); + } + + private handleLegacyEvent(event: MouseEvent | TouchEvent | PointerEvent): void { + // Determine if it's a right-click (only applicable to mouse events) + const isRightClick = event instanceof MouseEvent && event.button === 2; + + this.processSelectionEvent(event, isRightClick); + } + + private processSelectionEvent(event: Event, isRightClick: boolean): void { if (!event.target) return; + const selection = window.getSelection(); - // NOTE: This doesn't account for a partial occlusion with an annotation + // Check if target has annotation class this.hasSelectedAnnotation.set((event.target as HTMLElement).classList.contains('epub-highlight')); - if ((selection === null || selection === undefined || selection.toString().trim() === '' - || selection.toString().trim() === this.selectedText) || this.hasSelectedAnnotation()) { + if (this.shouldSkipSelection(selection, isRightClick)) { if (this.selectedText !== '') { event.preventDefault(); event.stopPropagation(); } - const isRightClick = (event instanceof MouseEvent && event.button === 2); if (!isRightClick) { this.reset(); } - return; } + // Process valid selection this.selectedText = selection ? selection.toString().trim() : ''; - - if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None) { - - // Get the range from the selection - const range = selection.getRangeAt(0); - - // Get start and end containers - const startContainer = this.getElementContainer(range.startContainer); - const endContainer = this.getElementContainer(range.endContainer); - - // Generate XPaths for both start and end - this.startXPath = this.readerService.getXPathTo(startContainer); - this.endXPath = this.readerService.getXPathTo(endContainer); - - // Protect from DOM Shift by removing the UI part and making this scoped to true epub html - this.startXPath = this.readerService.descopeBookReaderXpath(this.startXPath); - this.endXPath = this.readerService.descopeBookReaderXpath(this.endXPath); - - // Get the context window for generating a blurb in annotation flow - this.allTextFromSelection = (event.target as Element).textContent || ''; + if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None && selection !== null) { + this.captureSelectionContext(selection, event.target as Element); this.isOpen.emit(true); event.preventDefault(); event.stopPropagation(); } + this.cdRef.markForCheck(); } + private shouldSkipSelection(selection: Selection | null, isRightClick: boolean): boolean { + return (selection === null || + selection === undefined || + selection.toString().trim() === '' || + selection.toString().trim() === this.selectedText) || + this.hasSelectedAnnotation(); + } + + /** + * Captures XPath and context for the current selection + */ + private captureSelectionContext(selection: Selection, targetElement: Element): void { + const range = selection.getRangeAt(0); + + // Get start and end containers + const startContainer = this.getElementContainer(range.startContainer); + const endContainer = this.getElementContainer(range.endContainer); + + // Generate XPaths for both start and end + this.startXPath = this.readerService.getXPathTo(startContainer); + this.endXPath = this.readerService.getXPathTo(endContainer); + + // Protect from DOM Shift by removing the UI part and making this scoped to true epub html + this.startXPath = this.readerService.descopeBookReaderXpath(this.startXPath); + this.endXPath = this.readerService.descopeBookReaderXpath(this.endXPath); + + // Get the context window for generating a blurb in annotation flow + this.allTextFromSelection = targetElement.textContent || ''; + } + + switchMode(mode: BookLineOverlayMode) { this.mode = mode; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/book-reader/_models/annotations/annotation.ts b/UI/Web/src/app/book-reader/_models/annotations/annotation.ts index 2e8bcda87..939d8695b 100644 --- a/UI/Web/src/app/book-reader/_models/annotations/annotation.ts +++ b/UI/Web/src/app/book-reader/_models/annotations/annotation.ts @@ -12,6 +12,7 @@ export interface Annotation { selectedSlotIndex: number; chapterTitle: string | null; highlightCount: number; + likes: number[]; ownerUserId: number; ownerUsername: string; createdUtc: string; @@ -25,4 +26,7 @@ export interface Annotation { volumeId: number; seriesId: number; + seriesName: string; + libraryName: string; + } diff --git a/UI/Web/src/app/cards/bulk-selection.service.ts b/UI/Web/src/app/cards/bulk-selection.service.ts index 6d1c1ecbe..0520bff8d 100644 --- a/UI/Web/src/app/cards/bulk-selection.service.ts +++ b/UI/Web/src/app/cards/bulk-selection.service.ts @@ -74,6 +74,7 @@ export class BulkSelectionService { } this.prevIndex = index; this.prevDataSource = dataSource; + this.debugLog("Setting max for " + dataSource + " to " + maxIndex); this.dataSourceMax[dataSource] = maxIndex; this.actionsSource.next(this.getActions(() => {})); } diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index 3767cf96a..d8ffa55b1 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -265,7 +265,9 @@ export class CardDetailLayoutComponent + [mangaFormat]="series.format" + [totalBytes]="size" + /> @@ -166,9 +168,7 @@ [genres]="chapter.genres" [tags]="chapter.tags" [webLinks]="weblinks" - [readingTime]="chapter" - [language]="chapter.language" - [format]="series.format" /> + /> } diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index c79d05cc6..ea9926a81 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -177,6 +177,7 @@ export class ChapterDetailComponent implements OnInit { rating: number = 0; ratings: Array = []; hasBeenRated: boolean = false; + size: number = 0; annotations = model([]); weblinks: Array = []; @@ -263,6 +264,7 @@ export class ChapterDetailComponent implements OnInit { this.series = results.series; this.chapter = results.chapter; + this.size = this.chapter.files.reduce((sum, f) => sum + f.bytes, 0); this.weblinks = this.chapter.webLinks.split(','); this.libraryType = results.libraryType; this.userReviews = results.chapterDetail.reviews.filter(r => !r.isExternal); diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts index 8a43a99a8..f9c85a198 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts +++ b/UI/Web/src/app/metadata-filter/_components/metadata-filter-row/metadata-filter-row.component.ts @@ -15,18 +15,7 @@ import { } from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {FilterStatement} from '../../../_models/metadata/v2/filter-statement'; -import { - BehaviorSubject, - distinctUntilChanged, - filter, - map, - Observable, - of, - pipe, - startWith, - switchMap, - tap -} from 'rxjs'; +import {BehaviorSubject, distinctUntilChanged, filter, map, Observable, of, startWith, switchMap, tap} from 'rxjs'; import {MetadataService} from 'src/app/_services/metadata.service'; import {FilterComparison} from 'src/app/_models/metadata/v2/filter-comparison'; import {FilterField} from 'src/app/_models/metadata/v2/filter-field'; @@ -35,7 +24,7 @@ import {FilterComparisonPipe} from "../../../_pipes/filter-comparison.pipe"; import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {Select2, Select2Option} from "ng-select2-component"; import {NgbDate, NgbDateParserFormatter, NgbInputDatepicker, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; -import {TranslocoDirective, TranslocoService} from "@jsverse/transloco"; +import {TranslocoDirective} from "@jsverse/transloco"; import {ValidFilterEntity} from "../../filter-settings"; import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; import {AnnotationsFilterField} from "../../../_models/metadata/v2/annotations-filter"; @@ -132,7 +121,6 @@ export class MetadataFilterRowComponent @defer (when activeTabId === TabID.Details; prefetch on idle) { + /> } diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html index b19702592..6bdad5ecb 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.html @@ -17,21 +17,36 @@ @if ((libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) && mangaFormat !== MangaFormat.PDF) { - {{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}} + {{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}} } @else { - {{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}} + {{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}} } @if (hasReadingProgress && readingTimeLeft && readingTimeLeft.avgHours !== 0) { - + {{readingTimeLeft | readTimeLeft }} } @else { - + {{readingTimeEntity | readTime }} } + + @if (releaseYear) { + + + {{releaseYear}} + + } + + @if (totalBytes) { + + + {{totalBytes | bytes}} + + } + diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss index 42eb06de9..443dfe745 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.scss @@ -11,10 +11,6 @@ } } -.time-left{ - font-size: 0.8rem; -} - -.word-count { - font-size: 0.8rem; +.small-text { + font-size: 0.8rem; } diff --git a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts index 13e24c3c8..6987f77ce 100644 --- a/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts +++ b/UI/Web/src/app/series-detail/_components/metadata-detail-row/metadata-detail-row.component.ts @@ -18,20 +18,22 @@ import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {MangaFormat} from "../../../_models/manga-format"; import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component"; import {PublisherFlipperComponent} from "../../../_single-module/publisher-flipper/publisher-flipper.component"; +import {BytesPipe} from "../../../_pipes/bytes.pipe"; @Component({ selector: 'app-metadata-detail-row', - imports: [ - AgeRatingImageComponent, - CompactNumberPipe, - ReadTimeLeftPipe, - ReadTimePipe, - NgbTooltip, - TranslocoDirective, - ImageComponent, - SeriesFormatComponent, - PublisherFlipperComponent - ], + imports: [ + AgeRatingImageComponent, + CompactNumberPipe, + ReadTimeLeftPipe, + ReadTimePipe, + NgbTooltip, + TranslocoDirective, + ImageComponent, + SeriesFormatComponent, + PublisherFlipperComponent, + BytesPipe + ], templateUrl: './metadata-detail-row.component.html', styleUrl: './metadata-detail-row.component.scss', changeDetection: ChangeDetectionStrategy.OnPush @@ -51,6 +53,8 @@ export class MetadataDetailRowComponent { @Input({required: true}) ageRating: AgeRating = AgeRating.Unknown; @Input({required: true}) libraryType!: LibraryType; @Input({required: true}) mangaFormat!: MangaFormat; + @Input() releaseYear: number | undefined; + @Input() totalBytes: number | undefined; openGeneric(queryParamName: FilterField, filter: string | number) { if (queryParamName === FilterField.None) return; diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 745215a18..ebbc2819c 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -15,7 +15,7 @@ @if(isLoadingExtra || isLoading) {
- loading... + {{t('loading')}}
}
@@ -33,7 +33,10 @@ [hasReadingProgress]="hasReadingProgress" [readingTimeEntity]="series" [libraryType]="libraryType" - [mangaFormat]="series.format" /> + [mangaFormat]="series.format" + [releaseYear]="seriesMetadata.releaseYear" + [totalBytes]="totalSize()" + />
@for(item of scroll.viewPortItems; let idx = $index; track item) { @if (item.isChapter) { - + } @else { - + } } @@ -254,7 +257,7 @@
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead + '_specials') { - + }
@@ -340,10 +343,8 @@ [genres]="seriesMetadata.genres" [tags]="seriesMetadata.tags" [webLinks]="WebLinks" - [readingTime]="series" - [releaseYear]="seriesMetadata.releaseYear" - [language]="seriesMetadata.language" - [format]="series.format" /> + [filePaths]="[series.folderPath]" + /> } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 141ce3131..93cfab260 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -9,7 +9,7 @@ import { ElementRef, inject, model, - OnInit, + OnInit, signal, ViewChild } from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; @@ -240,6 +240,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { isWantToRead: boolean = false; unreadCount: number = 0; totalCount: number = 0; + totalSize = signal(undefined); readingTimeLeft: HourEstimateRange | null = null; /** * Poster image for the Series @@ -865,6 +866,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.volumes = detail.volumes; this.storyChapters = detail.storylineChapters; + this.totalSize.set(detail.volumes.reduce((sum, v) => sum + v.chapters.reduce((volumeSum, c) => { + return volumeSum + c.files.reduce((chapterSum, f) => chapterSum + f.bytes , 0) + }, 0), 0)); + this.storylineItems = []; const v = this.volumes.map(v => { return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem; diff --git a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html new file mode 100644 index 000000000..59dfc1fdb --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html @@ -0,0 +1,46 @@ + +
+
+

{{title()}}

+ {{tooltip()}} +
+ + @if (!isLoading() && options().length > 0) { + + + + + } + +
+ + + +
+
    + @for (opt of options(); track opt.value; let index = $index) { +
  • +
    + + + @if (opt.colour) { + @let c = opt.colour; + + } +
    +
  • + } @empty { +
  • + {{t('no-data')}} +
  • + } +
+
+ +
diff --git a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.scss b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.scss new file mode 100644 index 000000000..5bc630155 --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.scss @@ -0,0 +1,8 @@ +.list-group-item { + border: none; +} + +.text-muted { + font-size: 14px; +} + diff --git a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts new file mode 100644 index 000000000..34adf5ac1 --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts @@ -0,0 +1,159 @@ +import { + ChangeDetectionStrategy, + Component, + computed, effect, + forwardRef, + input, model, + signal +} from '@angular/core'; +import {RgbaColor} from "../../../book-reader/_models/annotations/highlight-slot"; +import {ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule} from "@angular/forms"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {LoadingComponent} from "../../../shared/loading/loading.component"; +import {NgStyle} from "@angular/common"; + +/** + * An item to display in the SettingMultiCheckBox + */ +export interface MultiCheckBoxItem { + /** + * Label to display in the list + */ + label: string, + /** + * Value passed to the FormControl + */ + value: T, + /** + * Appends a dot after the label + */ + colour?: RgbaColor, + /** + * If the items checkbox should be disabled. Does not overwrite global disable + * @param value + * @param selected + */ + disableFunc?: (value: T, selected: T[]) => boolean, +} + +/** + * The SettingMultiCheckBox should be used when wanting to display all options, of which any may be selected at once. + * The component should have a formControlName bound to it of type FormControl. + * + * An example can be found in ManageUserPreferencesComponent + */ +@Component({ + selector: 'app-setting-multi-check-box', + imports: [ + TranslocoDirective, + LoadingComponent, + ReactiveFormsModule, + NgStyle + ], + standalone: true, + templateUrl: './setting-multi-check-box.component.html', + styleUrl: './setting-multi-check-box.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SettingMultiCheckBox), + multi: true, + } + ] +}) +export class SettingMultiCheckBox implements ControlValueAccessor { + + /** + * Title to display above the checkboxes + */ + title = input.required(); + /** + * Tooltip to display muted underneath the title + * @optional + */ + tooltip = input(''); + /** + * Loading indicator for the checkbox list + * @optional + */ + loading = input(undefined); + /** + * All possible options + */ + options = input.required[]>(); + /** + * Disable all checkboxes + */ + disabled = model(false); + + isLoading = computed(() => { + const loading = this.loading(); + return loading !== undefined && loading; + }); + allSelected = computed(() => this.options().length === this.selectedValues().length); + + selectedValues = signal([]); + + private _onChange: (value: T[]) => void = () => {}; + private _onTouched: () => void = () => {}; + + constructor() { + // Auto propagate changes to the FormGroup + effect(() => { + const selectedValues = this.selectedValues(); + this._onChange(selectedValues); + this._onTouched(); + }); + } + + writeValue(obj: T[]): void { + this.selectedValues.set(obj || []); + } + + registerOnChange(fn: (_: T[]) => void): void { + this._onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } + + isChecked(item: MultiCheckBoxItem) { + return this.selectedValues().includes(item.value); + } + + isDisabled(item: MultiCheckBoxItem) { + const disabled = this.disabled(); + const selected = this.selectedValues(); + + if (disabled) { + return true; + } + + return item.disableFunc && item.disableFunc(item.value, selected); + } + + onCheckboxChange(item: MultiCheckBoxItem, event: Event) { + const checked = (event.target as HTMLInputElement).checked; + + if (checked) { + this.selectedValues.update(x => [...x, item.value]); + } else { + this.selectedValues.update(x => x.filter(t => t !== item.value)); + } + } + + toggleAll() { + if (this.allSelected()) { + this.selectedValues.set([]); + } else { + this.selectedValues.set(this.options().map(opt => opt.value)); + } + } + +} diff --git a/UI/Web/src/app/settings/_components/setting-multi-text-field/setting-multi-text-field.component.html b/UI/Web/src/app/settings/_components/setting-multi-text-field/setting-multi-text-field.component.html new file mode 100644 index 000000000..076e24215 --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-multi-text-field/setting-multi-text-field.component.html @@ -0,0 +1,19 @@ + + + + @for(opt of selectedValues(); track opt) { + {{opt}} + } @empty { + {{null | defaultValue}} + } + + + + + + + + diff --git a/UI/Web/src/app/settings/_components/setting-multi-text-field/setting-multi-text-field.component.scss b/UI/Web/src/app/settings/_components/setting-multi-text-field/setting-multi-text-field.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/settings/_components/setting-multi-text-field/setting-multi-text-field.component.ts b/UI/Web/src/app/settings/_components/setting-multi-text-field/setting-multi-text-field.component.ts new file mode 100644 index 000000000..efb93f1a8 --- /dev/null +++ b/UI/Web/src/app/settings/_components/setting-multi-text-field/setting-multi-text-field.component.ts @@ -0,0 +1,117 @@ +import {ChangeDetectionStrategy, Component, computed, effect, forwardRef, input, signal} from '@angular/core'; +import {ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule} from "@angular/forms"; +import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; +import {SettingItemComponent} from "../setting-item/setting-item.component"; +import {TagBadgeComponent} from "../../../shared/tag-badge/tag-badge.component"; + +/** + * SettingMultiTextFieldComponent should be used when using a text area to input several comma seperated values. + * The component should have a formControlName bound to it of type FormControl. + * By default, T is assumed to be a string + * + * An example can be found in ManageOpenIDConnectComponent + */ +@Component({ + selector: 'app-setting-multi-text-field', + imports: [ + DefaultValuePipe, + FormsModule, + ReactiveFormsModule, + SettingItemComponent, + TagBadgeComponent + ], + templateUrl: './setting-multi-text-field.component.html', + styleUrl: './setting-multi-text-field.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SettingMultiTextFieldComponent), + multi: true, + } + ] +}) +export class SettingMultiTextFieldComponent implements ControlValueAccessor { + /** + * Convertor, required if your type is not a string + * @default trimmed string value + */ + valueConvertor = input<(s: string) => T>((t: string) => t.trim() as T); + /** + * String to value convertor, required if your type is not a string + * @default the value as string + */ + stringConvertor = input<(t: T) => string>((t: T) => (t as string)); + /** + * Filter, required if your type is not a string + * @default non empty strings + */ + valueFilter = input<(t: T) => boolean>((t: T) => (t as string).length > 0); + /** + * Title to display + */ + title = input.required(); + /** + * Tooltip to display + * @optional + */ + tooltip = input(''); + /** + * Loading indicator for the checkbox list + * @optional + */ + loading = input(undefined); + /** + * id for the textarea input + * @optional + */ + id = input(''); + + isLoading = computed(() => { + const loading = this.loading(); + return loading !== undefined && loading; + }); + textFieldValue = computed(() => this.selectedValues().map(this.stringConvertor()).join(',')) + selectedValues = signal([]); + disabled = signal(false); + + textFieldValueTracker = ''; + + private _onChange: (value: T[]) => void = () => {}; + private _onTouched: () => void = () => {}; + + constructor() { + // Auto propagate changes to the FormGroup + effect(() => { + const selectedValues = this.selectedValues(); + this._onChange(selectedValues); + this._onTouched(); + }); + } + + writeValue(obj: T[]): void { + this.selectedValues.set(obj || []); + this.textFieldValueTracker = obj.map(this.stringConvertor()).join(','); + } + + registerOnChange(fn: (_: T[]) => void): void { + this._onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + setDisabledState?(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } + + onTextFieldChange(event: Event) { + const input = (event.target as HTMLTextAreaElement).value; + this.selectedValues.set(input + .split(',') + .map(this.valueConvertor()) + .filter(this.valueFilter()) + ); + } +} diff --git a/UI/Web/src/app/shared/_services/filter-utilities.service.ts b/UI/Web/src/app/shared/_services/filter-utilities.service.ts index b736c3ab8..8041e365e 100644 --- a/UI/Web/src/app/shared/_services/filter-utilities.service.ts +++ b/UI/Web/src/app/shared/_services/filter-utilities.service.ts @@ -181,7 +181,7 @@ export class FilterUtilitiesService { case "annotation": return [ AnnotationsFilterField.Owner, AnnotationsFilterField.Library, - AnnotationsFilterField.HighlightSlots, + AnnotationsFilterField.HighlightSlots, AnnotationsFilterField.Series ] as T[]; case 'series': return [ diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 538d986c5..48545931e 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -242,9 +242,9 @@ export class UtilityService { return params; } - createPaginatedResult(response: any, paginatedVariable: PaginatedResult | undefined = undefined) { + createPaginatedResult(response: any, paginatedVariable: PaginatedResult | undefined = undefined) { if (paginatedVariable === undefined) { - paginatedVariable = new PaginatedResult(); + paginatedVariable = new PaginatedResult(); } if (response.body === null) { paginatedVariable.result = []; diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html index 295e0462e..57c3b7581 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html @@ -4,7 +4,7 @@

@if (user) { -
+

{{t('global-settings-title')}}

@@ -104,18 +104,90 @@

{{t('social-settings-title')}}

-
- - -
- -
-
-
-
- + +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ +
+ @if (settingsForm.get('socialPreferences')!.get('socialLibraries'); as control) { + + + + + {{lib.name}} + + + {{lib.name}} + + + + + } +
+ +
+ @if (settingsForm.get('socialPreferences')!.get('socialMaxAgeRating'); as control) { + + + {{control.value | ageRating}} + + + + + + } +
+ +
+ + +
+ +
+
+
+
+
diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts index 90e10425f..446e78d23 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts @@ -1,22 +1,65 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + computed, + DestroyRef, effect, + inject, + OnInit, + signal +} from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import {Preferences} from "../../_models/preferences/preferences"; import {AccountService} from "../../_services/account.service"; -import {BookService} from "../../book-reader/_services/book.service"; import {Title} from "@angular/platform-browser"; import {Router} from "@angular/router"; import {LocalizationService} from "../../_services/localization.service"; -import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {FormArray, FormControl, FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from "@angular/forms"; import {User} from "../../_models/user"; import {KavitaLocale} from "../../_models/metadata/language"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {debounceTime, distinctUntilChanged, filter, forkJoin, switchMap, tap} from "rxjs"; +import {debounceTime, distinctUntilChanged, filter, forkJoin, of, switchMap, tap} from "rxjs"; import {take} from "rxjs/operators"; import {AsyncPipe, DecimalPipe, TitleCasePipe} from "@angular/common"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; import {LicenseService} from "../../_services/license.service"; import {HighlightBarComponent} from "../../book-reader/_components/_annotations/highlight-bar/highlight-bar.component"; +import {SiteTheme} from "../../_models/preferences/site-theme"; +import {PageLayoutMode} from "../../_models/page-layout-mode"; +import {HighlightSlot} from "../../book-reader/_models/annotations/highlight-slot"; +import {AgeRating} from "../../_models/metadata/age-rating"; +import {LibraryService} from "../../_services/library.service"; +import {Library} from "../../_models/library/library"; +import {MetadataService} from "../../_services/metadata.service"; +import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; +import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; +import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; +import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings"; + +type UserPreferencesForm = FormGroup<{ + theme: FormControl, + globalPageLayoutMode: FormControl, + blurUnreadSummaries: FormControl, + promptForDownloadSize: FormControl, + noTransitions: FormControl, + collapseSeriesRelationships: FormControl, + locale: FormControl, + bookReaderHighlightSlots: FormArray>, + colorScapeEnabled: FormControl, + + aniListScrobblingEnabled: FormControl, + wantToReadSync: FormControl, + + socialPreferences: FormGroup<{ + shareReviews: FormControl, + shareAnnotations: FormControl, + viewOtherAnnotations: FormControl, + socialLibraries: FormControl, + socialMaxAgeRating: FormControl, + socialIncludeUnknowns: FormControl, + }>, +}> @Component({ selector: 'app-manga-user-preferences', @@ -28,7 +71,9 @@ import {HighlightBarComponent} from "../../book-reader/_components/_annotations/ SettingSwitchComponent, AsyncPipe, DecimalPipe, - HighlightBarComponent + HighlightBarComponent, + AgeRatingPipe, + TypeaheadComponent, ], templateUrl: './manage-user-preferences.component.html', styleUrl: './manage-user-preferences.component.scss', @@ -38,18 +83,28 @@ export class ManageUserPreferencesComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly accountService = inject(AccountService); - private readonly bookService = inject(BookService); private readonly titleService = inject(Title); private readonly router = inject(Router); private readonly cdRef = inject(ChangeDetectorRef); private readonly localizationService = inject(LocalizationService); protected readonly licenseService = inject(LicenseService); + private readonly libraryService = inject(LibraryService); + private readonly fb = inject(NonNullableFormBuilder); + private readonly metadataService = inject(MetadataService); + loading = signal(true); + ageRatings = signal([]); + libraries = signal([]); + libraryOptions = computed(() => this.libraries().map(l => { + return { label: l.name, value: l.id }; + })); + locales: Array = []; - settingsForm: FormGroup = new FormGroup({}); + settingsForm!: UserPreferencesForm; user: User | undefined = undefined; + libraryTypeAheadSettings = signal(new TypeaheadSettings()); get Locale() { if (!this.settingsForm.get('locale')) return 'English'; @@ -75,33 +130,54 @@ export class ManageUserPreferencesComponent implements OnInit { ngOnInit(): void { this.titleService.setTitle('Kavita - User Preferences'); + this.cdRef.markForCheck(); forkJoin({ user: this.accountService.currentUser$.pipe(take(1)), - pref: this.accountService.getPreferences() - }).subscribe(results => { - if (results.user === undefined) { + pref: this.accountService.getPreferences(), + libraries: this.libraryService.getLibraries(), + ageRatings: this.metadataService.getAllAgeRatings(), + }).subscribe(({user, pref, libraries, ageRatings}) => { + if (user === undefined) { this.router.navigateByUrl('/login'); return; } - this.user = results.user; - this.user.preferences = results.pref; + this.user = user; + this.user.preferences = pref; - this.settingsForm.addControl('theme', new FormControl(this.user.preferences.theme, [])); - this.settingsForm.addControl('globalPageLayoutMode', new FormControl(this.user.preferences.globalPageLayoutMode, [])); - this.settingsForm.addControl('blurUnreadSummaries', new FormControl(this.user.preferences.blurUnreadSummaries, [])); - this.settingsForm.addControl('promptForDownloadSize', new FormControl(this.user.preferences.promptForDownloadSize, [])); - this.settingsForm.addControl('noTransitions', new FormControl(this.user.preferences.noTransitions, [])); - this.settingsForm.addControl('collapseSeriesRelationships', new FormControl(this.user.preferences.collapseSeriesRelationships, [])); - this.settingsForm.addControl('shareReviews', new FormControl(this.user.preferences.shareReviews, [])); - this.settingsForm.addControl('locale', new FormControl(this.user.preferences.locale || 'en', [])); - this.settingsForm.addControl('colorScapeEnabled', new FormControl(this.user.preferences.colorScapeEnabled ?? true, [])); + this.loading.set(false); + this.libraries.set(libraries); + this.ageRatings.set([{ + value: AgeRating.NotApplicable, + title: '', + }, ...ageRatings]); - this.settingsForm.addControl('aniListScrobblingEnabled', new FormControl(this.user.preferences.aniListScrobblingEnabled || false, [])); - this.settingsForm.addControl('wantToReadSync', new FormControl(this.user.preferences.wantToReadSync || false, [])); - this.settingsForm.addControl('bookReaderHighlightSlots', new FormControl(this.user.preferences.bookReaderHighlightSlots, [])); + this.setupLibraryTypeAheadSettings(); + this.settingsForm = this.fb.group({ + theme: this.fb.control(pref.theme), + globalPageLayoutMode: this.fb.control(pref.globalPageLayoutMode), + blurUnreadSummaries: this.fb.control(pref.blurUnreadSummaries), + promptForDownloadSize: this.fb.control(pref.promptForDownloadSize), + noTransitions: this.fb.control(pref.noTransitions), + collapseSeriesRelationships: this.fb.control(pref.collapseSeriesRelationships), + locale: this.fb.control(pref.locale || 'en'), + bookReaderHighlightSlots: this.fb.array(pref.bookReaderHighlightSlots.map(s => this.fb.control(s))), + colorScapeEnabled: this.fb.control(pref.colorScapeEnabled), + + aniListScrobblingEnabled: this.fb.control(pref.aniListScrobblingEnabled), + wantToReadSync: this.fb.control(pref.wantToReadSync), + + socialPreferences: this.fb.group({ + shareReviews: this.fb.control(pref.socialPreferences.shareReviews), + shareAnnotations: this.fb.control(pref.socialPreferences.shareAnnotations), + viewOtherAnnotations: this.fb.control(pref.socialPreferences.viewOtherAnnotations), + socialLibraries: this.fb.control(pref.socialPreferences.socialLibraries), + socialMaxAgeRating: this.fb.control(pref.socialPreferences.socialMaxAgeRating), + socialIncludeUnknowns: this.fb.control(pref.socialPreferences.socialIncludeUnknowns), + }), + }); // Automatically save settings as we edit them this.settingsForm.valueChanges.pipe( @@ -123,50 +199,31 @@ export class ManageUserPreferencesComponent implements OnInit { this.cdRef.markForCheck(); }); - - this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(mode => { - if (mode) { - this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true); - this.cdRef.markForCheck(); - } - }); - this.cdRef.markForCheck(); } - reset() { - if (!this.user) return; + private setupLibraryTypeAheadSettings() { + const libs = this.libraries(); + const selectedLibs = this.user!.preferences.socialPreferences.socialLibraries; - this.settingsForm.get('theme')?.setValue(this.user.preferences.theme, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('globalPageLayoutMode')?.setValue(this.user.preferences.globalPageLayoutMode, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('blurUnreadSummaries')?.setValue(this.user.preferences.blurUnreadSummaries, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('promptForDownloadSize')?.setValue(this.user.preferences.promptForDownloadSize, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('noTransitions')?.setValue(this.user.preferences.noTransitions, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('collapseSeriesRelationships')?.setValue(this.user.preferences.collapseSeriesRelationships, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('shareReviews')?.setValue(this.user.preferences.shareReviews, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('locale')?.setValue(this.user.preferences.locale || 'en', {onlySelf: true, emitEvent: false}); - this.settingsForm.get('colorScapeEnabled')?.setValue(this.user.preferences.colorScapeEnabled ?? true, {onlySelf: true, emitEvent: false}); + const settings = new TypeaheadSettings(); + settings.multiple = true; + settings.minCharacters = 0; + settings.savedData = libs.filter(l => selectedLibs.includes(l.id)); + settings.compareFn = (libs, filter) => libs.filter(l => l.name.toLowerCase().includes(filter.toLowerCase())); + settings.trackByIdentityFn = (idx, l) => `${l.id}`; + settings.fetchFn = (filter) => of(settings.compareFn(libs, filter)); - this.settingsForm.get('aniListScrobblingEnabled')?.setValue(this.user.preferences.aniListScrobblingEnabled || false, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('wantToReadSync')?.setValue(this.user.preferences.wantToReadSync || false, {onlySelf: true, emitEvent: false}); - this.settingsForm.get('bookReaderHighlightSlots')?.setValue(this.user.preferences.bookReaderHighlightSlots, {onlySelf: true, emitEvent: false}); + this.libraryTypeAheadSettings.set(settings); + } + + syncFormWithTypeahead(libs: Library[] | Library) { + this.settingsForm + .get('socialPreferences')! + .get('socialLibraries')! + .setValue((libs as Library[]).map(l => l.id)); } packSettings(): Preferences { - const modelSettings = this.settingsForm.value; - - return { - theme: modelSettings.theme, - globalPageLayoutMode: parseInt(modelSettings.globalPageLayoutMode, 10), - blurUnreadSummaries: modelSettings.blurUnreadSummaries, - promptForDownloadSize: modelSettings.promptForDownloadSize, - noTransitions: modelSettings.noTransitions, - collapseSeriesRelationships: modelSettings.collapseSeriesRelationships, - shareReviews: modelSettings.shareReviews, - locale: modelSettings.locale || 'en', - aniListScrobblingEnabled: modelSettings.aniListScrobblingEnabled, - wantToReadSync: modelSettings.wantToReadSync, - bookReaderHighlightSlots: modelSettings.bookReaderHighlightSlots, - colorScapeEnabled: modelSettings.colorScapeEnabled, - }; + return this.settingsForm.getRawValue(); } } diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index fa82a1f92..e1232313a 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -26,7 +26,9 @@ [hasReadingProgress]="volume.pagesRead > 0" [readingTimeEntity]="volume" [libraryType]="libraryType" - [mangaFormat]="series.format" /> + [mangaFormat]="series.format" + [totalBytes]="size" + /> @if (libraryType !== null && series && volume.chapters.length === 1) {
@@ -226,9 +228,7 @@ + /> } diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index a932e008c..69f54ec71 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -216,6 +216,7 @@ export class VolumeDetailComponent implements OnInit { plusReviews: Array = []; rating: number = 0; hasBeenRated: boolean = false; + size: number = 0; annotations = model([]); mobileSeriesImgBackground: string | undefined; @@ -407,6 +408,8 @@ export class VolumeDetailComponent implements OnInit { this.series = results.series; this.volume = results.volume; + this.size = this.volume.chapters.reduce((sum, c) => + sum + c.files.reduce((fileSum, f) => fileSum + f.bytes, 0), 0); this.libraryType = results.libraryType; if (this.volume.chapters.length === 1) { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 4b85bffd1..317c48587 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -54,6 +54,8 @@ "default-age-restriction-label": "Age rating", "default-age-restriction-tooltip": "Maximum age rating shown to new users", "no-restriction": "{{restriction-selector.no-restriction}}", + "default-roles-label": "{{common.roles}}", + "default-libraries-label": "{{common.libraries}}", "advanced-title": "Advanced settings", "advanced-tooltip": "Only modify these options if you understand their impact.", @@ -95,7 +97,9 @@ "out-of-sync": "This user was created via OIDC. If the SyncUsers setting is enabled, any changes made to this account may be overwritten.", "oidc-managed": "This user is managed via OIDC. Please contact your OIDC administrator to request any changes.", "identity-provider": "Identity provider", - "identity-provider-tooltip": "Kavita users are never synced with OIDC accounts." + "identity-provider-tooltip": "Kavita users are never synced with OIDC accounts.", + "libraries-label": "{{common.libraries}}", + "roles-label": "{{common.roles}}" }, "user-scrobble-history": { @@ -202,8 +206,6 @@ "disable-animations-tooltip": "Turns off animations in the site. Useful for e-ink readers.", "collapse-series-relationships-label": "Collapse Series Relationships", "collapse-series-relationships-tooltip": "Should Kavita show Series that have no relationships or is the parent/prequel", - "share-series-reviews-label": "Share Series Reviews", - "share-series-reviews-tooltip": "Allow your reviews to be visible to other users on this server", "highlight-bar-label": "Annotations highlight colors", "highlight-bar-tooltip": "These colors are shared between all books", "colorscape-label": "Use ColorScape", @@ -215,6 +217,19 @@ "want-to-read-sync-label": "Want To Read Sync", "want-to-read-sync-tooltip": "Allow Kavita to add items to your Want to Read list based on AniList and MAL series in Pending read list", + "share-series-reviews-label": "Share Series Reviews", + "share-series-reviews-tooltip": "Allow your reviews to be visible to other users on this server", + "share-annotations-label": "Share Annotations", + "share-annotations-tooltip": "Allow your annotations to be visible to other users on this server", + "view-other-annotations-label": "View Shared Annotations", + "view-other-annotations-tooltip": "View other users' annotations while reading", + "social-libraries-label": "Social Libraries", + "social-libraries-tooltip": "Libraries on which social features should be enabled. No selection enables social features everywhere. Disable specific features if you wish to use them nowhere.", + "social-max-age-rating-label": "Max age rating", + "social-max-age-rating-tooltip": "Max age rating on which social features will work", + "social-include-unknowns-label": "Include unknowns", + "social-include-unknowns-tooltip": "Enable social features for series and chapters with an unknown age rating", + "clients-opds-alert": "OPDS is not enabled on this server. This will not affect Mihon users.", "clients-opds-description": "All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.", "clients-api-key-tooltip": "The API key is like a password. Resetting it will invalidate any existing clients.", @@ -226,6 +241,11 @@ "save": "{{common.save}}" }, + "multi-check-box-form": { + "select-all": "Select all", + "deselect-all": "Deselect all" + }, + "user-holds": { "title": "Scrobble Holds", "description": "This is a user-managed list of Series that will not be scrobbled to upstream providers. You can remove a series at any time and the next scrobble-able event (reading progress, rating, want to read status) will trigger scrobbling.", @@ -471,7 +491,7 @@ }, "role-selector": { - "title": "Roles", + "title": "{{common.roles}}", "deselect-all": "{{common.deselect-all}}", "select-all": "{{common.select-all}}" }, @@ -748,11 +768,13 @@ "inviting": "Inviting…", "cancel": "{{common.cancel}}", "email-not-sent": "{{toasts.email-not-sent}}", - "notice": "{{manage-settings.notice}}" + "notice": "{{manage-settings.notice}}", + "libraries-label": "{{common.libraries}}", + "roles-label": "{{common.roles}}" }, "library-selector": { - "title": "Libraries", + "title": "{{common.libraries}}", "select-all": "{{common.select-all}}", "deselect-all": "{{common.deselect-all}}", "no-data": "There are no libraries setup yet." @@ -907,7 +929,7 @@ "field-mapping-description": "Setup rules for certain strings found in Genre/Tag field and map it to a new string in Genre/Tag and optionally remove it from the Source list. Matching is normalized and case-insensitive, the first matching value is used. Only applicable when Genre/Tag are enabled to be written.", "first-last-name-label": "First Last Naming", "first-last-name-tooltip": "Ensure People's names are written First then Last", - "person-roles-label": "Roles", + "person-roles-label": "{{common.roles}}", "overrides-label": "Overrides", "overrides-description": "Allow Kavita to write over locked fields.", "chapter-header": "Chapter Fields", @@ -931,7 +953,10 @@ "annotation-card": { "page-num": "{{book-reader.page-num-label}}", "contains-spoilers-label": "Spoilers", - "view-in-reader-label": "View in Reader" + "view-in-reader-label": "View in Reader", + "location-tooltip": "{{library}} - {{series}}", + "liked": "Liked by {{amount}} people, including you", + "not-liked": "Liked by {{amount}} people, you did not like it" }, "annotations-tab": { @@ -1143,7 +1168,8 @@ "publication-status-title": "Publication", "publication-status-tooltip": "Publication Status", "on": "{{reader-settings.on}}", - "off": "{{reader-settings.off}}" + "off": "{{reader-settings.off}}", + "loading": "{{common.loading}}" }, "match-series-modal": { @@ -1252,7 +1278,7 @@ "cover-image-description": "{{edit-series-modal.cover-image-description}}", "issue-count": "{{common.issue-count}}", "series-count": "{{common.series-count}}", - "roles-label": "Roles", + "roles-label": "{{common.roles}}", "sort-label": "Sort", "name-label": "Name", "issue-count-label": "Issue Count", @@ -1289,7 +1315,7 @@ "individual-role-title": "As a {{role}}", "browse-person-title": "All Works of {{name}}", "browse-person-by-role-title": "All Works of {{name}} as a {{role}}", - "all-roles": "Roles", + "all-roles": "{{common.roles}}", "anilist-url": "{{edit-person-modal.anilist-tooltip}}", "no-info": "No information about this Person" }, @@ -1462,7 +1488,8 @@ "release-title": "{{sort-field-pipe.release-year}}", "format-title": "{{metadata-filter.format-label}}", "length-title": "{{edit-chapter-modal.words-label}}", - "age-rating-title": "{{metadata-fields.age-rating-title}}" + "age-rating-title": "{{metadata-fields.age-rating-title}}", + "file-path-title": "Folder path" }, "related-tab": { @@ -1598,7 +1625,7 @@ }, "manage-library": { - "title": "Libraries", + "title": "{{common.libraries}}", "add-library": "Add Library", "no-data": "There are no libraries. Try creating one.", "loading": "{{common.loading}}", @@ -1836,7 +1863,7 @@ "resend": "Resend", "setup": "Setup", "last-active-header": "Last Active", - "roles-header": "Roles", + "roles-header": "{{common.roles}}", "name-header": "Name", "none": "None", "never": "Never", @@ -1908,7 +1935,7 @@ "admin-general": "General", "admin-oidc": "OpenID Connect", "admin-users": "Users", - "admin-libraries": "Libraries", + "admin-libraries": "{{common.libraries}}", "admin-media": "Media", "admin-media-issues": "Media Issues", "admin-logs": "Logs", @@ -2052,7 +2079,7 @@ "annotations": "{{tabs.annotations-tab}}", "genres": "{{metadata-fields.genres-title}}", "bookmarks": "{{side-nav.bookmarks}}", - "libraries": "Libraries", + "libraries": "{{common.libraries}}", "reading-lists": "Reading Lists", "collections": "Collections", "close": "{{common.close}}", @@ -2298,7 +2325,7 @@ "limit-label": "Limit", "format-label": "Format", - "libraries-label": "Libraries", + "libraries-label": "{{common.libraries}}", "collections-label": "Collections", "genres-label": "{{metadata-fields.genres-title}}", "tags-label": "{{metadata-fields.tags-title}}", @@ -2813,7 +2840,7 @@ "team": "Team", "location": "Location", "languages": "Languages", - "libraries": "Libraries", + "libraries": "{{common.libraries}}", "letterer": "Letterer", "publication-status": "Publication Status", "penciller": "Penciller", @@ -2909,7 +2936,8 @@ "annotation-spoiler": "Spoiler", "annotation-highlights": "Highlight color", "annotation-comment": "Annotation Text", - "annotation-selection": "Highlight Text" + "annotation-selection": "Highlight Text", + "series": "{{tabs.series-tab}}" }, @@ -3155,7 +3183,9 @@ "rename": "Rename", "rename-tooltip": "Rename the Smart Filter", "merge": "Merge", - "export": "Export" + "export": "Export", + "like": "Like", + "unlike": "Remove likes" }, "preferences": { @@ -3373,7 +3403,9 @@ "book-nums": "Books", "issue-nums": "Issues", "chapter-nums": "Chapters", - "volume-nums": "Volumes" + "volume-nums": "Volumes", + "roles": "Roles", + "libraries": "Libraries" } }