mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-02-07 11:33:31 -05:00
Social interactions with annotations (#4068)
Co-authored-by: Joe Milazzo <josephmajora@gmail.com>
This commit is contained in:
parent
d4e3a2de3e
commit
b40734265b
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
234
API.Tests/Services/AnnotationServiceTests.cs
Normal file
234
API.Tests/Services/AnnotationServiceTests.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
423
API.Tests/Services/OpdsServiceTests.cs
Normal file
423
API.Tests/Services/OpdsServiceTests.cs
Normal 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
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
BIN
API.Tests/Services/Test Data/OpdsService/test.zip
Normal file
BIN
API.Tests/Services/Test Data/OpdsService/test.zip
Normal file
Binary file not shown.
@ -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"
|
||||
]
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -87,4 +87,5 @@ public enum AnnotationFilterField
|
||||
/// This is the text the user wrote
|
||||
/// </summary>
|
||||
Comment = 6,
|
||||
Series = 7
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
3925
API/Data/Migrations/20251003110154_SocialAnnotations.Designer.cs
generated
Normal file
3925
API/Data/Migrations/20251003110154_SocialAnnotations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
API/Data/Migrations/20251003110154_SocialAnnotations.cs
Normal file
61
API/Data/Migrations/20251003110154_SocialAnnotations.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
17
API/Extensions/DataContextExtensions.cs
Normal file
17
API/Extensions/DataContextExtensions.cs
Normal 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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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]),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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
27
API/redo-migration.sh
Executable 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
|
||||
44
UI/Web/package-lock.json
generated
44
UI/Web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -8,6 +8,7 @@ export enum AnnotationsFilterField {
|
||||
HighlightSlots = 4,
|
||||
Selection = 5,
|
||||
Comment = 6,
|
||||
Series = 7
|
||||
}
|
||||
|
||||
export const allAnnotationsFilterFields = Object.keys(AnnotationsFilterField)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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([]);
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
@ -1,3 +0,0 @@
|
||||
.list-group-item {
|
||||
border: none;
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 || [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -1,3 +0,0 @@
|
||||
.list-group-item {
|
||||
border: none;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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)"
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
@ -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(() => {}));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -11,10 +11,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.time-left{
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-size: 0.8rem;
|
||||
.small-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
@ -0,0 +1,8 @@
|
||||
.list-group-item {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user