Social interactions with annotations (#4068)

Co-authored-by: Joe Milazzo <josephmajora@gmail.com>
This commit is contained in:
Fesaa 2025-10-04 22:11:06 +02:00 committed by GitHub
parent d4e3a2de3e
commit b40734265b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
107 changed files with 7615 additions and 1402 deletions

View File

@ -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;

View File

@ -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<Series>()
var items = new List<Series>
{
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<AppUserCollection>()
var items = new List<AppUserCollection>
{
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<Genre>()
var items = new List<Genre>
{
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<Tag>()
var items = new List<Tag>
{
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<Person>
{
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<ReadingList>()
var items = new List<ReadingList>
{
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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true),
CreateUserPreferences(2, [1], AgeRating.NotApplicable, true, true, true),
CreateUserPreferences(3, [], AgeRating.NotApplicable, true, false, true)
];
IList<AppUserAnnotation> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true),
CreateUserPreferences(2, [], AgeRating.Mature, includeUnknowns, true, true)
];
IList<AppUserAnnotation> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.NotApplicable, true, false, true), // User 1 NOT sharing
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true)
];
IList<AppUserAnnotation> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, false), // User 1 NOT viewing others
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true)
];
IList<AppUserAnnotation> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [1], AgeRating.NotApplicable, true, true, true), // User 1 only wants lib 1
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true)
];
IList<AppUserAnnotation> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.Teen, false, true, true), // User 1 wants Teen max, no unknowns
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true)
];
IList<AppUserAnnotation> 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<AppUserPreferences> 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<AppUserAnnotation> 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<AppUserPreferences> 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<AppUserAnnotation> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.NotApplicable, true, false, true),
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, false, true)
];
IList<AppUserAnnotation> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], maxRating, includeUnknowns, true, true),
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true)
];
IList<AppUserAnnotation> 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<int> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true),
CreateUserPreferences(2, [1], AgeRating.NotApplicable, true, true, true),
CreateUserPreferences(3, [], AgeRating.NotApplicable, true, false, true)
];
IList<AppUserRating> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true),
CreateUserPreferences(2, [], AgeRating.Mature, includeUnknowns, true, true)
];
IList<AppUserRating> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.NotApplicable, true, false, true),
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true)
];
IList<AppUserRating> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [1], AgeRating.NotApplicable, true, true, true),
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true)
];
IList<AppUserRating> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.Teen, false, true, true),
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true)
];
IList<AppUserRating> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], AgeRating.NotApplicable, true, true, true),
CreateUserPreferences(2, [1], AgeRating.Teen, true, true, true)
];
IList<AppUserRating> 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<AppUserPreferences> 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<AppUserRating> 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<AppUserPreferences> userPreferences =
[
CreateUserPreferences(1, [], maxRating, includeUnknowns, true, true),
CreateUserPreferences(2, [], AgeRating.NotApplicable, true, true, true)
];
IList<AppUserRating> 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
}
}
};
}
}

View File

@ -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<KavitaException>(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<KavitaException>(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<KavitaException>(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 = "<p>Something New</p>";
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("<p>Something New</p>", dto.CommentHtml);
Assert.Equal("Something New", dto.CommentPlainText);
// Ensure event was sent out to UI
await eventHub.Received().SendMessageToAsync(
MessageFactory.AnnotationUpdate,
Arg.Any<SignalRMessage>(),
user.Id);
}
[Fact]
public async Task ExportAnnotationsCorrectExportUser()
{
var unitOfWork = Substitute.For<IUnitOfWork>();
var annotationRepo = Substitute.For<IAnnotationRepository>();
var settingsRepo = Substitute.For<ISettingsRepository>();
unitOfWork.AnnotationRepository.Returns(annotationRepo);
unitOfWork.SettingsRepository.Returns(settingsRepo);
settingsRepo.GetSettingsDtoAsync().Returns(new ServerSettingDto
{
HostName = "",
});
var annotationService = new AnnotationService(
Substitute.For<ILogger<AnnotationService>>(),
unitOfWork,
Substitute.For<IBookService>(),
Substitute.For<IEventHub>());
await annotationService.ExportAnnotations(1);
await annotationRepo.Received().GetFullAnnotationsByUserIdAsync(1);
await annotationRepo.DidNotReceive().GetFullAnnotations(1, []);
}
[Fact]
public async Task ExportAnnotationsCorrectExportSpecific()
{
var unitOfWork = Substitute.For<IUnitOfWork>();
var annotationRepo = Substitute.For<IAnnotationRepository>();
var settingsRepo = Substitute.For<ISettingsRepository>();
unitOfWork.AnnotationRepository.Returns(annotationRepo);
unitOfWork.SettingsRepository.Returns(settingsRepo);
settingsRepo.GetSettingsDtoAsync().Returns(new ServerSettingDto
{
HostName = "",
});
var annotationService = new AnnotationService(
Substitute.For<ILogger<AnnotationService>>(),
unitOfWork,
Substitute.For<IBookService>(),
Substitute.For<IEventHub>());
List<int> 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<AnnotationDto> 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<ILogger<AccountService>>(),
unitOfWork,
mapper,
Substitute.For<ILocalizationService>()
).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<IBookService>();
var eventHub = Substitute.For<IEventHub>();
var annotationService = new AnnotationService(
Substitute.For<ILogger<AnnotationService>>(),
unitOfWork, bookService, eventHub);
return (user, annotationService, bookService, chapter, eventHub);
}
}

View File

@ -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<IOpdsService, IReaderService> SetupService(IUnitOfWork unitOfWork, IMapper mapper)
{
JobStorage.Current = new InMemoryStorage();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
var readerService = new ReaderService(unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(), ds, Substitute.For<IScrobblingService>());
var localizationService =
new LocalizationService(ds, new MockHostingEnvironment(), Substitute.For<IMemoryCache>(), unitOfWork);
var seriesService = new SeriesService(unitOfWork, Substitute.For<IEventHub>(), Substitute.For<ITaskScheduler>(),
Substitute.For<ILogger<SeriesService>>(), Substitute.For<IScrobblingService>(),
localizationService, Substitute.For<IReadingListService>());
var opdsService = new OpdsService(unitOfWork, localizationService,
seriesService, Substitute.For<DownloadService>(),
ds, readerService, mapper);
return new Tuple<IOpdsService, IReaderService>(opdsService, readerService);
}
private async Task<AppUser> 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
}

View File

@ -26,7 +26,7 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb
{
private readonly ITestOutputHelper _testOutputHelper = testOutputHelper;
private async Task<ReaderService> Setup(IUnitOfWork unitOfWork)
private ReaderService Setup(IUnitOfWork unitOfWork)
{
return new ReaderService(unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
@ -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);

View File

@ -1057,6 +1057,56 @@ public class ScannerServiceTests: AbstractDbTest
Assert.Contains(postLib.Series, x => x.Name == "Futoku No Guild");
}
/// <summary>
/// Test that if we have 3 directories with
/// </summary>
[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<string, ComicInfo>
{
{"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()
{

Binary file not shown.

View File

@ -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"
]

View File

@ -21,23 +21,14 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers;
public class AnnotationController : BaseApiController
public class AnnotationController(
IUnitOfWork unitOfWork,
ILogger<AnnotationController> logger,
ILocalizationService localizationService,
IEventHub eventHub,
IAnnotationService annotationService)
: BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<AnnotationController> _logger;
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
private readonly IAnnotationService _annotationService;
public AnnotationController(IUnitOfWork unitOfWork, ILogger<AnnotationController> logger,
ILocalizationService localizationService, IEventHub eventHub, IAnnotationService annotationService)
{
_unitOfWork = unitOfWork;
_logger = logger;
_localizationService = localizationService;
_eventHub = eventHub;
_annotationService = annotationService;
}
/// <summary>
/// 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<ActionResult<IEnumerable<AnnotationDto>>> GetAnnotations(int chapterId)
{
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
return Ok(await unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
}
/// <summary>
@ -75,7 +66,7 @@ public class AnnotationController : BaseApiController
[HttpGet("all-for-series")]
public async Task<ActionResult<AnnotationDto>> GetAnnotationsBySeries(int seriesId)
{
return Ok(await _unitOfWork.UserRepository.GetAnnotationDtosBySeries(User.GetUserId(), seriesId));
return Ok(await unitOfWork.UserRepository.GetAnnotationDtosBySeries(User.GetUserId(), seriesId));
}
/// <summary>
@ -86,7 +77,7 @@ public class AnnotationController : BaseApiController
[HttpGet("{annotationId}")]
public async Task<ActionResult<AnnotationDto>> GetAnnotation(int annotationId)
{
return Ok(await _unitOfWork.UserRepository.GetAnnotationDtoById(User.GetUserId(), annotationId));
return Ok(await unitOfWork.UserRepository.GetAnnotationDtoById(User.GetUserId(), annotationId));
}
/// <summary>
@ -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));
}
}
/// <summary>
/// Adds a like for the currently authenticated user if not already from the annotations with given ids
/// </summary>
/// <param name="ids"></param>
/// <returns></returns>
[HttpPost("like")]
public async Task<ActionResult> LikeAnnotations(IList<int> 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();
}
/// <summary>
/// Removes likes for the currently authenticated user if present from the annotations with given ids
/// </summary>
/// <param name="ids"></param>
/// <returns></returns>
[HttpPost("unlike")]
public async Task<ActionResult> UnLikeAnnotations(IList<int> 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();
}
/// <summary>
/// Delete the annotation for the user
/// </summary>
@ -133,11 +186,11 @@ public class AnnotationController : BaseApiController
[HttpDelete]
public async Task<ActionResult> 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<IActionResult> ExportAnnotations(IList<int>? 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);

View File

@ -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)

View File

@ -87,11 +87,11 @@ public class OpdsController : BaseApiController
private async Task<Tuple<string, string>> 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<string, string>(baseUrl, prefix);
@ -103,7 +103,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/smart-filters/{filterId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> 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
/// <returns></returns>
[HttpGet("{apiKey}/smart-filters")]
[Produces("application/xml")]
public async Task<IActionResult> GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetSmartFilters(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -162,7 +162,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/libraries")]
[Produces("application/xml")]
public async Task<IActionResult> GetLibraries(string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetLibraries(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -193,7 +193,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/want-to-read")]
[Produces("application/xml")]
public async Task<IActionResult> GetWantToRead(string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetWantToRead(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -224,7 +224,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/collections")]
[Produces("application/xml")]
public async Task<IActionResult> GetCollections(string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetCollections(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -256,7 +256,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/collections/{collectionId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -288,7 +288,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/reading-list")]
[Produces("application/xml")]
public async Task<IActionResult> GetReadingLists(string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetReadingLists(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -320,7 +320,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/reading-list/{readingListId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -354,7 +354,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/libraries/{libraryId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -386,7 +386,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/recently-added")]
[Produces("application/xml")]
public async Task<IActionResult> GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -417,7 +417,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/more-in-genre")]
[Produces("application/xml")]
public async Task<IActionResult> GetMoreInGenre(string apiKey, [FromQuery] int genreId, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetMoreInGenre(string apiKey, [FromQuery] int genreId, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -448,7 +448,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/recently-updated")]
[Produces("application/xml")]
public async Task<IActionResult> GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{
@ -478,7 +478,7 @@ public class OpdsController : BaseApiController
/// <returns></returns>
[HttpGet("{apiKey}/on-deck")]
[Produces("application/xml")]
public async Task<IActionResult> GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1)
public async Task<IActionResult> GetOnDeck(string apiKey, [FromQuery] int pageNumber = OpdsService.FirstPageNumber)
{
try
{

View File

@ -665,4 +665,16 @@ public class SeriesController : BaseApiController
return Ok();
}
/// <summary>
/// Returns all Series that a user has access to
/// </summary>
/// <returns></returns>
[HttpGet("series-with-annotations")]
public async Task<ActionResult<IList<SeriesDto>>> GetSeriesWithAnnotations()
{
var data = await _unitOfWork.AnnotationRepository.GetSeriesWithAnnotations(User.GetUserId());
return Ok(data);
}
}

View File

@ -42,6 +42,16 @@ public class UsersController : BaseApiController
public async Task<ActionResult> 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;

View File

@ -87,4 +87,5 @@ public enum AnnotationFilterField
/// This is the text the user wrote
/// </summary>
Comment = 6,
Series = 7
}

View File

@ -53,6 +53,12 @@ public sealed record AnnotationDto
/// </summary>
public int SelectedSlotIndex { get; set; }
/// <inheritdoc cref="AppUserAnnotation.Likes"/>
public ISet<int> 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; }

View File

@ -31,9 +31,6 @@ public sealed record UserPreferencesDto
/// <inheritdoc cref="API.Entities.AppUserPreferences.CollapseSeriesRelationships"/>
[Required]
public bool CollapseSeriesRelationships { get; set; } = false;
/// <inheritdoc cref="API.Entities.AppUserPreferences.ShareReviews"/>
[Required]
public bool ShareReviews { get; set; } = false;
/// <inheritdoc cref="API.Entities.AppUserPreferences.Locale"/>
[Required]
public string Locale { get; set; }
@ -48,4 +45,11 @@ public sealed record UserPreferencesDto
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderHighlightSlots"/>
[Required]
public List<HighlightSlot> BookReaderHighlightSlots { get; set; }
#region Social
/// <inheritdoc cref="AppUserPreferences.SocialPreferences"/>
public AppUserSocialPreferences SocialPreferences { get; set; } = new();
#endregion
}

View File

@ -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<AppUser, AppRole, int,
builder.Entity<MetadataSettings>()
.Property(x => x.AgeRatingMappings)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<Dictionary<string, AgeRating>>(v, JsonSerializerOptions.Default) ?? new Dictionary<string, AgeRating>()
);
.HasJsonConversion([]);
// Ensure blacklist is stored as a JSON array
builder.Entity<MetadataSettings>()
.Property(x => x.Blacklist)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default) ?? new List<string>()
);
.HasJsonConversion([]);
builder.Entity<MetadataSettings>()
.Property(x => x.Whitelist)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<string>>(v, JsonSerializerOptions.Default) ?? new List<string>()
);
.HasJsonConversion([]);
builder.Entity<MetadataSettings>()
.Property(x => x.Overrides)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<MetadataSettingField>>(v, JsonSerializerOptions.Default) ?? new List<MetadataSettingField>()
);
.HasJsonConversion([]);
// Configure one-to-many relationship
builder.Entity<MetadataSettings>()
@ -280,44 +269,45 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<AppUserReadingProfile>()
.Property(rp => rp.LibraryIds)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<int>>(v, JsonSerializerOptions.Default) ?? new List<int>())
.HasJsonConversion([])
.HasColumnType("TEXT");
builder.Entity<AppUserReadingProfile>()
.Property(rp => rp.SeriesIds)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<int>>(v, JsonSerializerOptions.Default) ?? new List<int>())
.HasJsonConversion([])
.HasColumnType("TEXT");
builder.Entity<SeriesMetadata>()
.Property(sm => sm.KPlusOverrides)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<IList<MetadataSettingField>>(v, JsonSerializerOptions.Default) ??
new List<MetadataSettingField>())
.HasJsonConversion([])
.HasColumnType("TEXT")
.HasDefaultValue(new List<MetadataSettingField>());
builder.Entity<Chapter>()
.Property(sm => sm.KPlusOverrides)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<IList<MetadataSettingField>>(v, JsonSerializerOptions.Default) ?? new List<MetadataSettingField>())
.HasJsonConversion([])
.HasColumnType("TEXT")
.HasDefaultValue(new List<MetadataSettingField>());
builder.Entity<AppUserPreferences>()
.Property(a => a.BookReaderHighlightSlots)
.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<List<HighlightSlot>>(v, JsonSerializerOptions.Default) ?? new List<HighlightSlot>())
.HasJsonConversion([])
.HasColumnType("TEXT")
.HasDefaultValue(new List<HighlightSlot>());
builder.Entity<AppUser>()
.Property(user => user.IdentityProvider)
.HasDefaultValue(IdentityProvider.Kavita);
builder.Entity<AppUserPreferences>()
.Property(a => a.SocialPreferences)
.HasJsonConversion(new AppUserSocialPreferences())
.HasColumnType("TEXT")
.HasDefaultValue(new AppUserSocialPreferences());
builder.Entity<AppUserAnnotation>()
.Property(a => a.Likes)
.HasJsonConversion(new HashSet<int>())
.HasColumnType("TEXT")
.HasDefaultValue(new HashSet<int>());
}
#nullable enable

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class SocialAnnotations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "SocialPreferences",
table: "AppUserPreferences",
type: "TEXT",
nullable: true,
defaultValue: "{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true}");
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
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");
}
}
}

View File

@ -213,6 +213,11 @@ namespace API.Data.Migrations
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Likes")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("[]");
b.Property<int>("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<bool>("ShowScreenHints")
.HasColumnType("INTEGER");
b.Property<string>("SocialPreferences")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true}");
b.Property<bool>("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");
});

View File

@ -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<AppUserAnnotation> annotations);
Task<AnnotationDto?> GetAnnotationDto(int id);
Task<AppUserAnnotation?> GetAnnotation(int id);
Task<IList<AppUserAnnotation>> GetAnnotations(IList<int> ids);
Task<IList<AppUserAnnotation>> GetAllAnnotations();
Task<IList<AppUserAnnotation>> GetAnnotations(int userId, IList<int> ids);
Task<IList<FullAnnotationDto>> GetFullAnnotationsByUserIdAsync(int userId);
Task<IList<FullAnnotationDto>> GetFullAnnotations(int userId, IList<int> annotationIds);
Task<PagedList<AnnotationDto>> GetAnnotationDtos(int userId, BrowseAnnotationFilterDto filter, UserParams userParams);
Task<List<SeriesDto>> 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<IList<AppUserAnnotation>> GetAnnotations(IList<int> ids)
public async Task<IList<AppUserAnnotation>> GetAllAnnotations()
{
return await context.AppUserAnnotation.ToListAsync();
}
public async Task<IList<AppUserAnnotation>> GetAnnotations(int userId, IList<int> 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<AnnotationDto>.CreateAsync(query, userParams);
}
public async Task<List<SeriesDto>> 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<SeriesDto>(mapper.ConfigurationProvider)
.ToListAsync();
}
private async Task<IQueryable<AnnotationDto>> 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<int>) value),
AnnotationFilterField.Library => query.IsInLibrary(true, statement.Comparison, (IList<int>) value),
AnnotationFilterField.Series => query.HasSeries(true, statement.Comparison, (IList<int>) value),
AnnotationFilterField.HighlightSlot => query.IsUsingHighlights(true, statement.Comparison, (IList<int>) 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<IList<FullAnnotationDto>> GetFullAnnotations(int userId, IList<int> 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<FullAnnotationDto>(mapper.ConfigurationProvider)
.OrderFullAnnotation()
.ToListAsync();
}
@ -166,9 +200,12 @@ public class AnnotationRepository(DataContext context, IMapper mapper) : IAnnota
/// <returns></returns>
public async Task<IList<FullAnnotationDto>> GetFullAnnotationsByUserIdAsync(int userId)
{
var userPreferences = await context.AppUserPreferences.ToListAsync();
return await context.AppUserAnnotation
.Where(a => a.AppUserId == userId)
.SelectFullAnnotation()
.RestrictBySocialPreferences(userId, userPreferences)
.ProjectTo<FullAnnotationDto>(mapper.ConfigurationProvider)
.OrderFullAnnotation()
.ToListAsync();
}
}

View File

@ -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);
}

View File

@ -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<IEnumerable<SeriesDto>> 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<PagedList<SeriesDto>> 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
/// <returns></returns>
public async Task<PagedList<SeriesDto>> 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<SeriesDto?> 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<SeriesDto?> 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<Series?> GetSeriesByAnyName(string seriesName, string localizedName, IList<MangaFormat> 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<Series?> GetSeriesByAnyName(IList<string> names, IList<MangaFormat> 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<PagedList<SeriesDto>> 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<PagedList<SeriesDto>> 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<PagedList<SeriesDto>> 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;
}
/// <summary>
/// Returns all library ids for a user
/// </summary>
/// <param name="userId"></param>
/// <param name="libraryId">0 for no library filter</param>
/// <param name="queryContext">Defaults to None - The context behind this query, so appropriate restrictions can be placed</param>
/// <returns></returns>
private IQueryable<int> 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);
}
}

View File

@ -587,9 +587,11 @@ public class UserRepository : IUserRepository
/// <returns></returns>
public async Task<List<AnnotationDto>> 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<AnnotationDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -597,9 +599,11 @@ public class UserRepository : IUserRepository
public async Task<List<AnnotationDto>> 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<AnnotationDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -617,16 +621,22 @@ public class UserRepository : IUserRepository
public async Task<AnnotationDto?> 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<AnnotationDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
}
public async Task<List<AnnotationDto>> 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<AnnotationDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
@ -686,10 +696,12 @@ public class UserRepository : IUserRepository
public async Task<IList<UserReviewDto>> 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<IList<UserReviewDto>> 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,

View File

@ -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?)
/// <summary>
/// A set container userIds of all users who have liked this annotations
/// </summary>
public ISet<int> Likes { get; set; } = new HashSet<int>();
/// <summary>
/// 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; }

View File

@ -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
/// </summary>
public bool CollapseSeriesRelationships { get; set; } = false;
/// <summary>
/// UI Site Global Setting: Should series reviews be shared with all users in the server
/// </summary>
public bool ShareReviews { get; set; } = false;
/// <summary>
/// UI Site Global Setting: The language locale that should be used for the user
/// </summary>
public string Locale { get; set; }
@ -185,8 +182,60 @@ public class AppUserPreferences
/// Should this account have Want to Read Sync enabled
/// </summary>
public bool WantToReadSync { get; set; }
#endregion
#region Social
/// <summary>
/// UI Site Global Setting: Should series reviews be shared with all users in the server
/// </summary>
[Obsolete("Use SocialPreferences.ShareReviews")]
public bool ShareReviews { get; set; } = false;
/// <summary>
/// The social preferences of the AppUser
/// </summary>
/// <remarks>Saved as a JSON obj in the DB</remarks>
public AppUserSocialPreferences SocialPreferences { get; set; } = new();
#endregion
public AppUser AppUser { get; set; } = null!;
public int AppUserId { get; set; }
}
public class AppUserSocialPreferences
{
/// <summary>
/// UI Site Global Setting: Should series reviews be shared with all users in the server
/// </summary>
public bool ShareReviews { get; set; } = false;
/// <summary>
/// UI Site Global Setting: Share your annotations with other users
/// </summary>
public bool ShareAnnotations { get; set; } = false;
/// <summary>
/// UI Site Global Setting: See other users' annotations while reading
/// </summary>
public bool ViewOtherAnnotations { get; set; } = false;
/// <summary>
/// UI Site Global Setting: For which libraries should social features be enabled
/// </summary>
/// <remarks>Empty array means all, disable specific social features to opt out everywhere</remarks>
public IList<int> SocialLibraries { get; set; } = [];
/// <summary>
/// UI Site Global Setting: Highest age rating for which social features are enabled
/// </summary>
public AgeRating SocialMaxAgeRating { get; set; } = AgeRating.NotApplicable;
/// <summary>
/// UI Site Global Setting: Enable social features for unknown age ratings
/// </summary>
public bool SocialIncludeUnknowns { get; set; } = true;
}

View File

@ -22,7 +22,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasKP
/// Smallest number of the Range. Can be a partial like Chapter 4.5
/// </summary>
[Obsolete("Use MinNumber and MaxNumber instead")]
public required string Number { get; set; }
public string Number { get; set; }
/// <summary>
/// Minimum Chapter Number.
/// </summary>

View File

@ -0,0 +1,17 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace API.Extensions;
public static class DataContextExtensions
{
public static PropertyBuilder<TProperty> HasJsonConversion<TProperty>(this PropertyBuilder<TProperty> builder, TProperty def = default)
{
return builder.HasConversion(
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
v => JsonSerializer.Deserialize<TProperty>(v, JsonSerializerOptions.Default) ?? def
);
}
}

View File

@ -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<AppUserAnnotation> HasSeries(this IQueryable<AppUserAnnotation> queryable, bool condition,
FilterComparison comparison, IList<int> 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]),

View File

@ -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);
}
/// <summary>
/// Returns all library ids for a user
/// </summary>
/// <param name="userId"></param>
/// <param name="libraryId">0 for no library filter</param>
/// <param name="queryContext">Defaults to None - The context behind this query, so appropriate restrictions can be placed</param>
/// <returns></returns>
public static IQueryable<int> GetLibraryIdsForUser(this DbSet<AppUser> 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);
}
/// <summary>
/// Returns all libraries for a given user and library type
/// </summary>
@ -346,31 +375,9 @@ public static class QueryableExtensions
};
}
public static IQueryable<FullAnnotationDto> SelectFullAnnotation(this IQueryable<AppUserAnnotation> query)
public static IQueryable<FullAnnotationDto> OrderFullAnnotation(this IQueryable<FullAnnotationDto> 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)

View File

@ -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<AppUserRating> RestrictAgainstAgeRestriction(this IQueryable<AppUserRating> 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<AppUserChapterRating> RestrictAgainstAgeRestriction(this IQueryable<AppUserChapterRating> 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<AppUserAnnotation> RestrictAgainstAgeRestriction(this IQueryable<AppUserAnnotation> 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
/// <summary>
/// Filter annotations by social preferences of users
/// </summary>
/// <param name="queryable"></param>
/// <param name="userId"></param>
/// <param name="userPreferences">List of user preferences for every user on the server</param>
/// <returns></returns>
public static IQueryable<AppUserAnnotation> RestrictBySocialPreferences(this IQueryable<AppUserAnnotation> queryable, int userId, IList<AppUserPreferences> 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
/// <summary>
/// Filter user reviews social preferences of users
/// </summary>
/// <param name="queryable"></param>
/// <param name="userId"></param>
/// <param name="userPreferences">List of user preferences for every user on the server</param>
/// <returns></returns>
public static IQueryable<AppUserRating> RestrictBySocialPreferences(this IQueryable<AppUserRating> queryable, int userId, IList<AppUserPreferences> 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
/// <summary>
/// Filter user chapter reviews social preferences of users
/// </summary>
/// <param name="queryable"></param>
/// <param name="userId"></param>
/// <param name="userPreferences">List of user preferences for every user on the server</param>
/// <returns></returns>
public static IQueryable<AppUserChapterRating> RestrictBySocialPreferences(this IQueryable<AppUserChapterRating> queryable, int userId, IList<AppUserPreferences> 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);
}
}

View File

@ -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();

View File

@ -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<AppUserAnnotation, AnnotationDto>()
.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<AppUserAnnotation, FullAnnotationDto>()
.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<OidcConfigDto, OidcPublicConfigDto>();
}

View File

@ -23,16 +23,17 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
UserPreferences = new AppUserPreferences
{
Theme = theme ?? Seed.DefaultThemes.First(),
Locale = "en"
},
ReadingLists = new List<ReadingList>(),
Bookmarks = new List<AppUserBookmark>(),
Libraries = new List<Library>(),
Ratings = new List<AppUserRating>(),
Progresses = new List<AppUserProgress>(),
Devices = new List<Device>(),
ReadingLists = [],
Bookmarks = [],
Libraries = [],
Ratings = [],
Progresses = [],
Devices = [],
Id = 0,
DashboardStreams = new List<AppUserDashboardStream>(),
SideNavStreams = new List<AppUserSideNavStream>(),
DashboardStreams = [],
SideNavStreams = [],
ReadingProfiles = [],
};
}
@ -65,7 +66,7 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
public AppUserBuilder WithRole(string role)
{
_appUser.UserRoles ??= new List<AppUserRole>();
_appUser.UserRoles ??= [];
_appUser.UserRoles.Add(new AppUserRole() {Role = new AppRole() {Name = role}});
return this;
}

View File

@ -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,

View File

@ -33,23 +33,20 @@ public interface IAnnotationService
Task<string> ExportAnnotations(int userId, IList<int>? annotationIds = null);
}
public class AnnotationService : IAnnotationService
public class AnnotationService(
ILogger<AnnotationService> 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<AnnotationService> _logger;
public AnnotationService(IUnitOfWork unitOfWork, IBookService bookService,
IDirectoryService directoryService, IEventHub eventHub, ILogger<AnnotationService> 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
};
/// <summary>
/// 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<FullAnnotationDto> 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;
}
}

View File

@ -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
};
}

View File

@ -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");
}

View File

@ -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;

27
API/redo-migration.sh Executable file
View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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) {

View File

@ -8,6 +8,7 @@ export enum AnnotationsFilterField {
HighlightSlots = 4,
Selection = 5,
Comment = 6,
Series = 7
}
export const allAnnotationsFilterFields = Object.keys(AnnotationsFilterField)

View File

@ -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;
}

View File

@ -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');
}
}

View File

@ -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<string[]>(this.baseUrl + 'account/roles');
return this.httpClient.get<Role[]>(this.baseUrl + 'account/roles');
}
@ -201,8 +203,7 @@ export class AccountService {
if (user) {
this.setCurrentUser(user);
}
}),
takeUntilDestroyed(this.destroyRef)
})
);
}

View File

@ -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: [],
},
];

View File

@ -87,7 +87,7 @@ export class AnnotationService {
return this.httpClient.post<PaginatedResult<Annotation>[]>(this.baseUrl + 'annotation/all-filtered', filter, {observe: 'response', params}).pipe(
map((res: any) => {
return this.utilityService.createPaginatedResult(res as PaginatedResult<Annotation>[]);
return this.utilityService.createPaginatedResult<Annotation>(res);
}),
);
}
@ -114,7 +114,6 @@ export class AnnotationService {
return this.httpClient.post<Annotation>(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);
}
}

View File

@ -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 {

View File

@ -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([]);

View File

@ -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<string>(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse);
}
getSeriesWithAnnotations() {
return this.httpClient.get<Series[]>(this.baseUrl + 'series/series-with-annotations');
}
}

View File

@ -1,54 +1,18 @@
<ng-container *transloco="let t; prefix: 'details-tab'">
<div class="details pb-3">
@if (readingTime) {
@if (accountService.isAdmin() && filePaths && filePaths.length > 0) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('read-time-title')}}</h4>
<div class="ms-3">
{{readingTime | readTime}}
<h4 class="header">{{t('file-path-title')}}</h4>
<div class="ms-3 d-flex flex-column">
@for (fp of filePaths; track $index) {
{{fp}}
}
</div>
</div>
}
@if (releaseYear) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('release-title')}}</h4>
<div class="ms-3">
{{releaseYear}}
</div>
</div>
}
@if (language) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('language-title')}}</h4>
<div class="ms-3">
{{language | languageName | async}}
</div>
</div>
}
@if (ageRating) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('age-rating-title')}}</h4>
<div class="ms-3">
<app-age-rating-image [rating]="ageRating" />
</div>
</div>
}
@if (format) {
<div class="mb-3 ms-1">
<h4 class="header">{{t('format-title')}}</h4>
<div class="ms-3">
<app-series-format [format]="format" /> {{format | mangaFormat }}
</div>
</div>
}
@if (!suppressEmptyGenres || genres.length > 0) {
<div class="setting-section-break" aria-hidden="true"></div>
<div class="mb-3 ms-1">
<h4 class="header">{{t('genres-title')}}</h4>

View File

@ -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<Genre> = [];
@Input() tags: Array<Tag> = [];
@Input() webLinks: Array<string> = [];
@Input() suppressEmptyGenres: boolean = false;
@Input() suppressEmptyTags: boolean = false;
@Input() filePaths: string[] | undefined;
openGeneric(queryParamName: FilterField, filter: string | number) {

View File

@ -93,11 +93,19 @@
<div class="row g-0 mb-3">
<div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member()" />
<app-setting-multi-check-box
[title]="t('libraries-label')"
[options]="libraryOptions()"
formControlName="libraries"
/>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member()" />
<app-setting-multi-check-box
[title]="t('roles-label')"
[options]="roleOptions"
formControlName="roles"
/>
</div>
</div>
</form>

View File

@ -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<Member>();
settings = model.required<ServerSettings>();
@ -53,8 +57,16 @@ export class EditUserComponent implements OnInit {
return setting.oidcConfig.syncUserSettings && member.identityProvider === IdentityProvider.OpenIdConnect;
});
selectedRoles: Array<string> = [];
selectedLibraries: Array<number> = [];
libraries = signal<Library[]>([]);
libraryOptions = computed<MultiCheckBoxItem<number>[]>(() => this.libraries().map(l => {
return { label: l.name, value: l.id };
}));
roleOptions: MultiCheckBoxItem<Role>[] = 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<string>) {
this.selectedRoles = roles;
this.cdRef.markForCheck();
}
updateRestrictionSelection(restriction: AgeRestriction) {
this.selectedRestriction = restriction;
this.cdRef.markForCheck();
}
updateLibrarySelection(libraries: Array<Library>) {
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;

View File

@ -30,11 +30,19 @@
<div class="row g-0">
<div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" />
<app-setting-multi-check-box
[title]="t('libraries-label')"
[options]="libraryOptions()"
formControlName="libraries"
/>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)" />
<app-setting-multi-check-box
[title]="t('roles-label')"
[options]="roleOptions"
formControlName="roles"
/>
</div>
</div>

View File

@ -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<string> = [];
selectedLibraries: Array<number> = [];
inviteForm: FormGroup<{
email: FormControl<string>,
libraries: FormControl<number[]>,
roles: FormControl<Role[]>,
}> = new FormGroup({
email: new FormControl<string>(''),
libraries: new FormControl<number[]>([]),
roles: new FormControl<Role[]>([Role.Login]),
}) as any;
selectedRestriction: AgeRestriction = {ageRating: AgeRating.NotApplicable, includeUnknowns: false};
emailLink: string = '';
invited: boolean = false;
inviteError: boolean = false;
libraries = signal<Library[]>([]);
libraryOptions = computed<MultiCheckBoxItem<number>[]>(() => this.libraries().map(l => {
return { label: l.name, value: l.id };
}));
roleOptions: MultiCheckBoxItem<Role>[] = 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<string>) {
this.selectedRoles = roles;
this.cdRef.markForCheck();
}
updateLibrarySelection(libraries: Array<Library>) {
this.selectedLibraries = libraries.map(l => l.id);
this.cdRef.markForCheck();
}
updateRestrictionSelection(restriction: AgeRestriction) {
this.selectedRestriction = restriction;
this.cdRef.markForCheck();

View File

@ -1,42 +0,0 @@
<ng-container *transloco="let t; prefix: 'library-selector'">
<div class="d-flex justify-content-between">
<div class="col-auto">
<h4>{{t('title')}}</h4>
</div>
<div class="col-auto">
@if(!isLoading && allLibraries.length > 0) {
<span class="form-check float-end">
<input id="lib--select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="lib--select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</span>
}
</div>
</div>
@if (isLoading) {
<app-loading [loading]="isLoading" />
} @else {
<div class="list-group">
<ul class="ps-0">
@for (library of allLibraries; track library.name; let i = $index) {
<li class="list-group-item">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
} @empty {
<li class="list-group-item">
{{t('no-data')}}
</li>
}
</ul>
</div>
}
</ng-container>

View File

@ -1,3 +0,0 @@
.list-group-item {
border: none;
}

View File

@ -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<number[]>([]);
@Output() selected: EventEmitter<Array<Library>> = new EventEmitter<Array<Library>>();
allLibraries: Library[] = [];
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
selections!: SelectionModel<Library>;
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<Library>(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());
}
}

View File

@ -2,47 +2,21 @@
<div class="row g-0 align-items-start mb-4">
<div class="col">
@if(settingsForm().get('blacklist'); as formControl) {
<app-setting-item
[title]="t('blacklist-label')"
[subtitle]="t('blacklist-tooltip')">
<ng-template #view>
@let val = breakTags(formControl.value);
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>
<ng-template #edit>
<textarea rows="3" id="blacklist" class="form-control" formControlName="blacklist"></textarea>
</ng-template>
</app-setting-item>
}
<app-setting-multi-text-field
[title]="t('blacklist-label')"
[tooltip]="t('blacklist-tooltip')"
formControlName="blacklist"
/>
</div>
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm().get('whitelist'); as formControl) {
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
<ng-template #view>
@let val = breakTags(formControl.value);
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>s
<ng-template #edit>
<textarea rows="3" id="whitelist" class="form-control" formControlName="whitelist"></textarea>
</ng-template>
</app-setting-item>
}
<app-setting-multi-text-field
[title]="t('whitelist-label')"
[tooltip]="t('whitelist-tooltip')"
formControlName="whitelist"
/>
</div>
<div class="setting-section-break"></div>

View File

@ -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<string, AgeRating>,
@ -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<string, AgeRating>, 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 || [],
}
}

View File

@ -4,275 +4,274 @@
<button type="button" class="btn btn-primary position-absolute custom-position" (click)="save(true)">{{t('save')}}</button>
</div>
<form [formGroup]="settingsForm">
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
</div>
<h4>{{t('provider-title')}}</h4>
<div class="text-muted" [innerHtml]="t('provider-tooltip') | safeHtml"></div>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('authority'); as formControl) {
<app-setting-item [title]="t('authority-label')" [subtitle]="t('authority-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-authority" class="form-control"
formControlName="authority" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="invalid-uri-validation" class="invalid-feedback">
@if (formControl.errors?.invalidUri) {
<div>{{t('invalid-uri')}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
@if (!loading()) {
<form [formGroup]="settingsForm">
<div class="alert alert-warning" role="alert">
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('clientId'); as formControl) {
<app-setting-item [title]="t('client-id-label')" [subtitle]="t('client-id-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-client-id" aria-describedby="oidc-client-id-validations" class="form-control"
formControlName="clientId" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
<h4>{{t('provider-title')}}</h4>
<div class="text-muted" [innerHtml]="t('provider-tooltip') | safeHtml"></div>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('authority'); as formControl) {
<app-setting-item [title]="t('authority-label')" [subtitle]="t('authority-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-authority" class="form-control"
formControlName="authority" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-client-id-validations" class="invalid-feedback">
@if (formControl.errors && formControl.errors.requiredIf) {
<div>{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('secret'); as formControl) {
<app-setting-item [title]="t('secret-label')" [subtitle]="t('secret-tooltip')">
<ng-template #view>
{{formControl.value | defaultValue}}
</ng-template>
<ng-template #edit>
<input id="oid-secret" aria-describedby="oidc-secret-validations" class="form-control"
formControlName="secret" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-secret-validations" class="invalid-feedback">
@if (formControl.errors && formControl.errors.requiredIf) {
<div>{{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('behavior-title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('providerName'); as formControl) {
<app-setting-item [title]="t('provider-name-label')" [subtitle]="t('provider-name-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-provider-name" aria-describedby="oidc-provider-name-validations" class="form-control"
formControlName="providerName" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('provisionAccounts'); as formControl) {
<app-setting-switch [title]="t('provision-accounts-label')" [subtitle]="t('provision-accounts-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="provision-accounts" type="checkbox" class="form-check-input" formControlName="provisionAccounts">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('requireVerifiedEmail'); as formControl) {
<app-setting-switch [title]="t('require-verified-email-label')" [subtitle]="t('require-verified-email-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="require-verified-email" type="checkbox" class="form-check-input" formControlName="requireVerifiedEmail">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('syncUserSettings'); as formControl) {
<app-setting-switch [title]="t('sync-user-settings-label')" [subtitle]="t('sync-user-settings-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="sync-user-settings" type="checkbox" class="form-check-input" formControlName="syncUserSettings">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('autoLogin'); as formControl) {
<app-setting-switch [title]="t('auto-login-label')" [subtitle]="t('auto-login-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="auto-login" type="checkbox" class="form-check-input" formControlName="autoLogin">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('disablePasswordAuthentication'); as formControl) {
<app-setting-switch [title]="t('disable-password-authentication-label')" [subtitle]="t('disable-password-authentication-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="disable-password-authentication" type="checkbox" class="form-check-input" formControlName="disablePasswordAuthentication">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('defaults-title')}}</h4>
<div class="text-muted">{{t('defaults-requirement')}}</div>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('defaultAgeRestriction'); as formControl) {
<app-setting-item [title]="t('default-age-restriction-label')" [subtitle]="t('default-age-restriction-tooltip')">
<ng-template #view>
<div>{{formControl.value | ageRating}}</div>
</ng-template>
<ng-template #edit>
<select class="form-select" formControlName="defaultAgeRestriction">
<option value="-1">{{t('no-restriction')}}</option>
@for (ageRating of ageRatings(); track ageRating.value) {
<option [value]="ageRating.value">{{ageRating.title}}</option>
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="invalid-uri-validation" class="invalid-feedback">
@if (formControl.errors?.invalidUri) {
<div>{{t('invalid-uri')}}</div>
}
</div>
}
</select>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('defaultIncludeUnknowns'); as formControl) {
<app-setting-switch [title]="t('default-include-unknowns-label')" [subtitle]="t('default-include-unknowns-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="default-include-unknowns" type="checkbox" class="form-check-input" formControlName="defaultIncludeUnknowns">
</div>
</ng-template>
</app-setting-switch>
}
</div>
@if (oidcSettings()) {
<div class="row g-0 mb-3">
<div class="col-md-6 pe-4">
<app-role-selector (selected)="updateRoles($event)" [allowAdmin]="true" [preSelectedRoles]="selectedRoles()" />
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibraries($event)" [preSelectedLibraries]="selectedLibraries()" />
</div>
</ng-template>
</app-setting-item>
}
</div>
}
</ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('clientId'); as formControl) {
<app-setting-item [title]="t('client-id-label')" [subtitle]="t('client-id-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-client-id" aria-describedby="oidc-client-id-validations" class="form-control"
formControlName="clientId" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
<div class="setting-section-break"></div>
<h4>{{t('advanced-title')}}</h4>
<div class="text-muted">{{t('advanced-tooltip')}}</div>
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-client-id-validations" class="invalid-feedback">
@if (formControl.errors && formControl.errors.requiredIf) {
<div>{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}</div>
}
</div>
}
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('rolesPrefix'); as formControl) {
<app-setting-item [title]="t('roles-prefix-label')" [subtitle]="t('roles-prefix-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oidc-roles-prefix" class="form-control"
formControlName="rolesPrefix" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('secret'); as formControl) {
<app-setting-item [title]="t('secret-label')" [subtitle]="t('secret-tooltip')">
<ng-template #view>
{{formControl.value | defaultValue}}
</ng-template>
<ng-template #edit>
<input id="oid-secret" aria-describedby="oidc-secret-validations" class="form-control"
formControlName="secret" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-secret-validations" class="invalid-feedback">
@if (formControl.errors && formControl.errors.requiredIf) {
<div>{{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('behavior-title')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('providerName'); as formControl) {
<app-setting-item [title]="t('provider-name-label')" [subtitle]="t('provider-name-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-provider-name" aria-describedby="oidc-provider-name-validations" class="form-control"
formControlName="providerName" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('provisionAccounts'); as formControl) {
<app-setting-switch [title]="t('provision-accounts-label')" [subtitle]="t('provision-accounts-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="provision-accounts" type="checkbox" class="form-check-input" formControlName="provisionAccounts">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('requireVerifiedEmail'); as formControl) {
<app-setting-switch [title]="t('require-verified-email-label')" [subtitle]="t('require-verified-email-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="require-verified-email" type="checkbox" class="form-check-input" formControlName="requireVerifiedEmail">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('syncUserSettings'); as formControl) {
<app-setting-switch [title]="t('sync-user-settings-label')" [subtitle]="t('sync-user-settings-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="sync-user-settings" type="checkbox" class="form-check-input" formControlName="syncUserSettings">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('autoLogin'); as formControl) {
<app-setting-switch [title]="t('auto-login-label')" [subtitle]="t('auto-login-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="auto-login" type="checkbox" class="form-check-input" formControlName="autoLogin">
</div>
</ng-template>
</app-setting-switch>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('disablePasswordAuthentication'); as formControl) {
<app-setting-switch [title]="t('disable-password-authentication-label')" [subtitle]="t('disable-password-authentication-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="disable-password-authentication" type="checkbox" class="form-check-input" formControlName="disablePasswordAuthentication">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('defaults-title')}}</h4>
<div class="text-muted">{{t('defaults-requirement')}}</div>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('defaultAgeRestriction'); as formControl) {
<app-setting-item [title]="t('default-age-restriction-label')" [subtitle]="t('default-age-restriction-tooltip')">
<ng-template #view>
<div>{{formControl.value | ageRating}}</div>
</ng-template>
<ng-template #edit>
<select class="form-select" formControlName="defaultAgeRestriction">
<option value="-1">{{t('no-restriction')}}</option>
@for (ageRating of ageRatings(); track ageRating.value) {
<option [ngValue]="ageRating.value">{{ageRating.title}}</option>
}
</select>
</ng-template>
</app-setting-item>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('defaultIncludeUnknowns'); as formControl) {
<app-setting-switch [title]="t('default-include-unknowns-label')" [subtitle]="t('default-include-unknowns-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="default-include-unknowns" type="checkbox" class="form-check-input" formControlName="defaultIncludeUnknowns">
</div>
</ng-template>
</app-setting-switch>
}
</div>
@if (oidcSettings()) {
<div class="row g-0 mb-3">
<div class="col-md-6 pe-4">
<app-setting-multi-check-box
[title]="t('default-roles-label')"
[options]="roleOptions"
[loading]="loading()"
formControlName="defaultRoles"
/>
</div>
<div class="col-md-6">
<app-setting-multi-check-box
[title]="t('default-libraries-label')"
[options]="libraryOptions()"
[loading]="loading()"
formControlName="defaultLibraries"
/>
</div>
</div>
}
</div>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('rolesClaim'); as formControl) {
<app-setting-item [title]="t('roles-claim-label')" [subtitle]="t('roles-claim-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oidc-roles-claim" class="form-control"
formControlName="rolesClaim" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('customScopes'); as formControl) {
<app-setting-item [title]="t('custom-scopes-label')" [subtitle]="t('custom-scopes-tooltip')">
<ng-template #view>
@let val = breakString(formControl.value);
<div class="setting-section-break"></div>
<h4>{{t('advanced-title')}}</h4>
<div class="text-muted">{{t('advanced-tooltip')}}</div>
@for(opt of val; track opt) {
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('rolesPrefix'); as formControl) {
<app-setting-item [title]="t('roles-prefix-label')" [subtitle]="t('roles-prefix-tooltip')">
<ng-template #view>
{{formControl.value | defaultValue}}
</ng-template>
<ng-template #edit>
<input id="oidc-roles-prefix" class="form-control"
formControlName="rolesPrefix" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
<ng-template #edit>
<textarea rows="3" id="custom-scopes" class="form-control" formControlName="customScopes"></textarea>
</ng-template>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('rolesClaim'); as formControl) {
<app-setting-item [title]="t('roles-claim-label')" [subtitle]="t('roles-claim-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oidc-roles-claim" class="form-control"
formControlName="rolesClaim" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
</ng-template>
</app-setting-item>
}
</div>
</app-setting-item>
<div class="row g-0 mt-4 mb-4">
<app-setting-multi-text-field
[title]="t('custom-scopes-label')"
[tooltip]="t('custom-scopes-tooltip')"
[loading]="loading()"
formControlName="customScopes"
/>
</div>
}
</div>
</ng-container>
</ng-container>
</form>
</form>
}
</ng-container>

View File

@ -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<boolean>;
disablePasswordAuthentication: FormControl<boolean>;
providerName: FormControl<string>;
authority: FormControl<string>;
clientId: FormControl<string>;
secret: FormControl<string>;
provisionAccounts: FormControl<boolean>;
requireVerifiedEmail: FormControl<boolean>;
syncUserSettings: FormControl<boolean>;
rolesPrefix: FormControl<string>;
rolesClaim: FormControl<string>;
customScopes: FormControl<string[]>;
defaultRoles: FormControl<string[]>;
defaultLibraries: FormControl<number[]>;
defaultAgeRestriction: FormControl<AgeRating>;
defaultIncludeUnknowns: FormControl<boolean>;
}>;
@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<OidcConfig | undefined>(undefined);
ageRatings = signal<AgeRatingDto[]>([]);
selectedLibraries = signal<number[]>([]);
selectedRoles = signal<string[]>([]);
libraries = signal<Library[]>([]);
libraryOptions = computed(() => this.libraries().map(l => {
return { label: l.name, value: l.id };
}));
roles = signal<Role[]>(allRoles);
roleOptions: MultiCheckBoxItem<Role>[] = 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;

View File

@ -1,28 +0,0 @@
<ng-container *transloco="let t; prefix:'role-selector'">
<div class="d-flex justify-content-between">
<div class="col-auto">
<h4>{{t('title')}}</h4>
</div>
<div class="col-auto">
@if(selectedRoles.length > 0) {
<span class="form-check float-end">
<input id="role--select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="role--select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
</span>
}
</div>
</div>
<ul class="list-group">
@for(role of selectedRoles; track role; let i = $index) {
<li class="list-group-item">
<div class="form-check">
<input id="role-{{i}}" type="checkbox" class="form-check-input"
[(ngModel)]="role.selected" [disabled]="role.disabled" name="role" (ngModelChange)="handleModelUpdate()">
<label for="role-{{i}}" class="form-check-label">{{role.data | roleLocalized}}</label>
</div>
</li>
}
</ul>
</ng-container>

View File

@ -1,3 +0,0 @@
.list-group-item {
border: none;
}

View File

@ -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<string[]>([]);
/**
* Allows the selection of Admin role
*/
@Input() allowAdmin: boolean = false;
@Output() selected: EventEmitter<string[]> = new EventEmitter<string[]>();
allRoles: string[] = [];
selectedRoles: Array<{selected: boolean, disabled: boolean, data: string}> = [];
selections!: SelectionModel<string>;
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<string>(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();
}
}

View File

@ -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)"
/>

View File

@ -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<Annotation[]>([]);
@ -118,6 +120,7 @@ export class AllAnnotationsComponent implements OnInit {
}
handleAction = async (action: ActionItem<Annotation>, 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;

View File

@ -1,7 +1,11 @@
<ng-container *transloco="let t; prefix: 'annotation-card'">
<div class="card border-0 shadow-sm mb-3" [class.annotation-card]="forceSize()">
<div #username class="card-header d-flex justify-content-between align-items-center py-2 px-3 clickable" [ngStyle]="{'background-color': titleColor()}" (click)="viewAnnotation()">
<div class="d-flex align-items-center">
@if (showLocationInformation()) {
<i tabindex="0" class="fas fa-book me-2" [ngbTooltip]="t('location-tooltip', {series: annotation().seriesName, library: annotation().libraryName})"></i>
}
<strong [style.color]="colorscapeService.getContrastingTextColor(username)">{{ annotation().ownerUsername }}</strong>
</div>
<div [style.color]="colorscapeService.getContrastingTextColor(username)" class="ms-2">{{ annotation().createdUtc | utcToLocaleDate | date: 'shortDate' }}</div>
@ -53,18 +57,28 @@
}
</div>
@if(annotation().containsSpoiler) {
<div class="d-flex align-items-center">
<div class="d-flex flex-row justify-content-center align-items-center">
@if(annotation().containsSpoiler) {
<div class="d-flex align-items-center">
<span class="small text-muted">
<i class="fa-solid fa-circle-exclamation me-1" aria-hidden="true"></i>
{{t('contains-spoilers-label')}}
</span>
</div>
}
</div>
}
<app-annotation-likes
[annotation]="annotation()"
[visible]="showLikes()"
(annotationChange)="annotation.set($event)"
/>
@if (showSelectionBox()) {
<input class="form-check-input ms-2 mt-0" type="checkbox" [checked]="selected()" (change)="selection.emit(this.selected())">
}
</div>
@if (showSelectionBox()) {
<input class="form-check-input" type="checkbox" [checked]="selected()" (change)="selection.emit(this.selected())">
}
</div>
</div>

View File

@ -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<Annotation>();
allowEdit = input<boolean>(true);
@ -59,13 +71,31 @@ export class AnnotationCardComponent {
* Redirects to the reader with annotation in view
*/
showInReaderLink = input<boolean>(false);
/**
* Disable a selection checkbox. Fires selection when called
*/
showSelectionBox = input<boolean>(false);
/**
* Displays series and library name
*/
showLocationInformation = input<boolean>(false);
/**
* Disable a like button
*/
showLikes = input<boolean>(true);
openInIncognitoMode = input<boolean>(false);
isInReader = input<boolean>(true);
/**
* If enabled, listens to annotation updates
*/
listedToUpdates = input<boolean>(false);
selected = input<boolean>(false);
@Output() delete = new EventEmitter();
@Output() navigate = new EventEmitter<Annotation>();
/**
* Fire when the checkbox is pressed, with the last known state (inverse of checked state)
*/
@Output() selection = new EventEmitter<boolean>();
titleColor: Signal<string>;
@ -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();
});
}
}

View File

@ -0,0 +1,13 @@
<ng-container *transloco="let t; prefix: 'annotation-card'">
@if (visible()) {
<button (click)="handleLikeChange()" class="ms-2 btn-unstyled"
[class.clickable]="annotation().ownerUserId !== accountService.userId()"
[attr.aria-label]="liked()
? t('liked', {amount: annotation().likes.length})
: t('not-liked', {amount: annotation().likes.length})"
>
<span>{{annotation().likes.length}}</span>
<i class="{{liked() ? 'fas' : 'fa-regular'}} fa-thumbs-up ms-1"></i>
</button>
}
</ng-container>

View File

@ -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<boolean>();
/**
* The annotation for which the likes are shown. Will emit a annotationChange when the likes update
*/
annotation = model.required<Annotation>();
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();
}
}

View File

@ -30,7 +30,13 @@
}
@for(annotation of annotations() | filter: filterList; track annotation.comment + annotation.highlightColor + annotation.containsSpolier) {
<app-annotation-card [annotation]="annotation" (delete)="handleDelete(annotation)" (navigate)="handleNavigateTo($event)" [forceSize]="false" />
<app-annotation-card
[annotation]="annotation"
[allowEdit]="annotation.ownerUserId === accountService.currentUserSignal()!.id"
(delete)="handleDelete(annotation)"
(navigate)="handleNavigateTo($event)"
[forceSize]="false"
/>
}
@empty {
<p>{{t('no-data')}}</p>

View File

@ -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<Annotation> = new EventEmitter();

View File

@ -55,6 +55,13 @@
@case (AnnotationMode.View) {
@let an = annotation();
@if (an) {
<app-annotation-likes
[annotation]="an"
[visible]="true"
(annotationChange)="annotation.set($event)"
/>
<div class="card-header d-flex justify-content-between align-items-center py-2 px-3">
<div class="d-flex align-items-center">
{{ an.ownerUsername }}

View File

@ -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;
}

View File

@ -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<string>;
totalText!: Signal<SafeHtml>;
formGroup!: FormGroup<{
note: FormControl<object>,
hasSpoiler: FormControl<boolean>,
@ -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);
}
}

View File

@ -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<MouseEvent>(this.parent.nativeElement, 'mouseup');
const touchEnd$ = fromEvent<TouchEvent>(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<PointerEvent>(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<MouseEvent>(this.parent.nativeElement, 'mouseup');
const touchEnd$ = fromEvent<TouchEvent>(this.parent.nativeElement, 'touchend');
// Additional events for mobile Chromium workaround
const additionalEvents$ = isMobileChromium() ? [
fromEvent<TouchEvent>(this.parent.nativeElement, 'touchcancel'),
fromEvent<PointerEvent>(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();

View File

@ -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;
}

View File

@ -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(() => {}));
}

View File

@ -265,7 +265,9 @@ export class CardDetailLayoutComponent<TFilter extends number, TSort extends num
tryToSaveJumpKey(item: any) {
let name = '';
if (item.hasOwnProperty('seriesName')) {
if (item.hasOwnProperty('sortName')) {
name = item.sortName;
} else if (item.hasOwnProperty('seriesName')) {
name = item.seriesName;
} else if (item.hasOwnProperty('name')) {
name = item.name;

View File

@ -22,7 +22,9 @@
[hasReadingProgress]="chapter.pagesRead > 0"
[readingTimeEntity]="chapter"
[libraryType]="libraryType"
[mangaFormat]="series.format" />
[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" />
/>
}
</ng-template>
</li>

View File

@ -177,6 +177,7 @@ export class ChapterDetailComponent implements OnInit {
rating: number = 0;
ratings: Array<Rating> = [];
hasBeenRated: boolean = false;
size: number = 0;
annotations = model<Annotation[]>([]);
weblinks: Array<string> = [];
@ -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);

View File

@ -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<TFilter extends number = number, TSort e
private readonly destroyRef = inject(DestroyRef);
private readonly dateParser = inject(NgbDateParserFormatter);
private readonly metadataService = inject(MetadataService);
private readonly translocoService = inject(TranslocoService);
private readonly filterUtilitiesService = inject(FilterUtilitiesService);
private readonly injector = inject(Injector);

View File

@ -126,7 +126,6 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
this.navService.hideNavBar();
this.themeService.clearThemes();
this.navService.hideSideNav();
pdfDefaultOptions.disableAutoFetch = true;
}
@HostListener('window:keyup', ['$event'])

View File

@ -256,10 +256,9 @@
<ng-template ngbNavContent>
@defer (when activeTabId === TabID.Details; prefetch on idle) {
<app-details-tab [metadata]="castInfo"
[readingTime]="rlInfo"
[suppressEmptyGenres]="true"
[suppressEmptyTags]="true"
[ageRating]="readingList.ageRating"/>
/>
}
</ng-template>
</li>

View File

@ -17,21 +17,36 @@
</span>
@if ((libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) && mangaFormat !== MangaFormat.PDF) {
<span class="word-count me-3">{{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}}</span>
<span class="small-text me-3">{{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}}</span>
} @else {
<span class="word-count me-3">{{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}}</span>
<span class="small-text me-3">{{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}}</span>
}
@if (hasReadingProgress && readingTimeLeft && readingTimeLeft.avgHours !== 0) {
<span class="time-left" [ngbTooltip]="t('time-left-alt')">
<span class="small-text" [ngbTooltip]="t('time-left-alt')">
<i class="fa-solid fa-clock" aria-hidden="true"></i>
{{readingTimeLeft | readTimeLeft }}
</span>
} @else {
<span class="time-left" [ngbTooltip]="t('time-to-read-alt')">
<span class="small-text" [ngbTooltip]="t('time-to-read-alt')">
<i class="fa-regular fa-clock" aria-hidden="true"></i>
{{readingTimeEntity | readTime }}
</span>
}
@if (releaseYear) {
<span class="small-text ms-3">
<i class="fas fa-calendar" aria-hidden="true"></i>
{{releaseYear}}
</span>
}
@if (totalBytes) {
<span class="small-text ms-3">
<i class="fas fa-database" aria-hidden="true"></i>
{{totalBytes | bytes}}
</span>
}
</div>
</ng-container>

View File

@ -11,10 +11,6 @@
}
}
.time-left{
font-size: 0.8rem;
}
.word-count {
font-size: 0.8rem;
.small-text {
font-size: 0.8rem;
}

View File

@ -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;

View File

@ -15,7 +15,7 @@
@if(isLoadingExtra || isLoading) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">loading...</span>
<span class="visually-hidden">{{t('loading')}}</span>
</div>
}
</span>
@ -33,7 +33,10 @@
[hasReadingProgress]="hasReadingProgress"
[readingTimeEntity]="series"
[libraryType]="libraryType"
[mangaFormat]="series.format" />
[mangaFormat]="series.format"
[releaseYear]="seriesMetadata.releaseYear"
[totalBytes]="totalSize()"
/>
<div class="mt-2 mb-2">
<app-external-rating [seriesId]="series.id"
@ -186,9 +189,9 @@
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track item) {
@if (item.isChapter) {
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item.chapter, scroll: scroll, idx: idx, chaptersLength: storyChapters.length}" />
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item.chapter, scroll: scroll, idx: idx, totalLength: storyChapters.length}" />
} @else {
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item.volume, scroll: scroll, idx: idx, volumesLength: volumes.length}" />
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item.volume, scroll: scroll, idx: idx, totalLength: volumes.length}" />
}
}
@ -254,7 +257,7 @@
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock" [childHeight]="1">
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead + '_specials') {
<ng-container [ngTemplateOutlet]="specialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, chaptersLength: chapters().length}" />
<ng-container [ngTemplateOutlet]="specialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: chapters().length}" />
}
</div>
</virtual-scroller>
@ -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]"
/>
}
</ng-template>
</li>

View File

@ -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<number | undefined>(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;

View File

@ -0,0 +1,46 @@
<ng-container *transloco="let t; prefix: 'multi-check-box-form'">
<div class="d-flex justify-content-between">
<div class="w-75">
<h4>{{title()}}</h4>
<span class="text-muted d-block">{{tooltip()}}</span>
</div>
@if (!isLoading() && options().length > 0) {
<span class="form-check float-end" (click)="toggleAll()">
<input id="select-all" type="checkbox" class="form-check-input"
[indeterminate]="selectedValues().length > 0 && !allSelected()"
[checked]="allSelected()" [disabled]="disabled()"
>
<label class="form-check-label" for="select-all text-nowrap">{{ allSelected() ? t('deselect-all') : t('select-all')}}</label>
</span>
}
</div>
<app-loading [loading]="isLoading()" />
<div class="list-group mt-2">
<ul class="ps-0">
@for (opt of options(); track opt.value; let index = $index) {
<li class="list-group-item">
<div class="form-check">
<input id="option--{{index}}" type="checkbox" class="form-check-input"
[checked]="isChecked(opt)" (change)="onCheckboxChange(opt, $event)"
[disabled]="isDisabled(opt)"
>
<label class="form-check-label" for="option--{{index}}">{{opt.label}}</label>
@if (opt.colour) {
@let c = opt.colour;
<i class="fas fa-circle" [ngStyle]="{ 'color': `rgba(${c.r}, ${c.g}, ${c.b}, ${c.a})` }"></i>
}
</div>
</li>
} @empty {
<li class="list-group-item">
{{t('no-data')}}
</li>
}
</ul>
</div>
</ng-container>

View File

@ -0,0 +1,8 @@
.list-group-item {
border: none;
}
.text-muted {
font-size: 14px;
}

View File

@ -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<T> {
/**
* 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<T[]>.
*
* 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<any>),
multi: true,
}
]
})
export class SettingMultiCheckBox<T> implements ControlValueAccessor {
/**
* Title to display above the checkboxes
*/
title = input.required<string>();
/**
* Tooltip to display muted underneath the title
* @optional
*/
tooltip = input<string>('');
/**
* Loading indicator for the checkbox list
* @optional
*/
loading = input<boolean | undefined>(undefined);
/**
* All possible options
*/
options = input.required<MultiCheckBoxItem<T>[]>();
/**
* 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<T[]>([]);
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<T>) {
return this.selectedValues().includes(item.value);
}
isDisabled(item: MultiCheckBoxItem<T>) {
const disabled = this.disabled();
const selected = this.selectedValues();
if (disabled) {
return true;
}
return item.disableFunc && item.disableFunc(item.value, selected);
}
onCheckboxChange(item: MultiCheckBoxItem<T>, 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));
}
}
}

View File

@ -0,0 +1,19 @@
<ng-container>
<app-setting-item [title]="title()" [subtitle]="tooltip()">
<ng-template #view>
@for(opt of selectedValues(); track opt) {
<app-tag-badge>{{opt}}</app-tag-badge>
} @empty {
{{null | defaultValue}}
}
</ng-template>
<ng-template #edit>
<textarea rows="3" [id]="id()" class="form-control"
[value]="textFieldValue()"
(change)="onTextFieldChange($event)" [disabled]="disabled()">
</textarea>
</ng-template>
</app-setting-item>
</ng-container>

View File

@ -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<T[]>.
* 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<T> 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<string>();
/**
* Tooltip to display
* @optional
*/
tooltip = input<string>('');
/**
* Loading indicator for the checkbox list
* @optional
*/
loading = input<boolean | undefined>(undefined);
/**
* id for the textarea input
* @optional
*/
id = input<string>('');
isLoading = computed(() => {
const loading = this.loading();
return loading !== undefined && loading;
});
textFieldValue = computed(() => this.selectedValues().map(this.stringConvertor()).join(','))
selectedValues = signal<T[]>([]);
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())
);
}
}

Some files were not shown because too many files have changed in this diff Show More