diff --git a/API.Tests/Helpers/RandfHelper.cs b/API.Tests/Helpers/RandfHelper.cs new file mode 100644 index 000000000..d8c007df7 --- /dev/null +++ b/API.Tests/Helpers/RandfHelper.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace API.Tests.Helpers; + +public class RandfHelper +{ + private static readonly Random Random = new (); + + /// + /// Returns true if all simple fields are equal + /// + /// + /// + /// fields to ignore, note that the names are very weird sometimes + /// + /// + /// + public static bool AreSimpleFieldsEqual(object obj1, object obj2, IList ignoreFields) + { + if (obj1 == null || obj2 == null) + throw new ArgumentNullException("Neither object can be null."); + + Type type1 = obj1.GetType(); + Type type2 = obj2.GetType(); + + if (type1 != type2) + throw new ArgumentException("Objects must be of the same type."); + + FieldInfo[] fields = type1.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic); + + foreach (var field in fields) + { + if (field.IsInitOnly) continue; + if (ignoreFields.Contains(field.Name)) continue; + + Type fieldType = field.FieldType; + + if (IsRelevantType(fieldType)) + { + object value1 = field.GetValue(obj1); + object value2 = field.GetValue(obj2); + + if (!Equals(value1, value2)) + { + throw new ArgumentException("Fields must be of the same type: " + field.Name + " was " + value1 + " and " + value2); + } + } + } + + return true; + } + + private static bool IsRelevantType(Type type) + { + return type.IsPrimitive + || type == typeof(string) + || type.IsEnum; + } + + /// + /// Sets all simple fields of the given object to a random value + /// + /// + /// Simple is, primitive, string, or enum + /// + public static void SetRandomValues(object obj) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + + Type type = obj.GetType(); + FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + foreach (var field in fields) + { + if (field.IsInitOnly) continue; // Skip readonly fields + + object value = GenerateRandomValue(field.FieldType); + if (value != null) + { + field.SetValue(obj, value); + } + } + } + + private static object GenerateRandomValue(Type type) + { + if (type == typeof(int)) + return Random.Next(); + if (type == typeof(float)) + return (float)Random.NextDouble() * 100; + if (type == typeof(double)) + return Random.NextDouble() * 100; + if (type == typeof(bool)) + return Random.Next(2) == 1; + if (type == typeof(char)) + return (char)Random.Next('A', 'Z' + 1); + if (type == typeof(byte)) + return (byte)Random.Next(0, 256); + if (type == typeof(short)) + return (short)Random.Next(short.MinValue, short.MaxValue); + if (type == typeof(long)) + return (long)(Random.NextDouble() * long.MaxValue); + if (type == typeof(string)) + return GenerateRandomString(10); + if (type.IsEnum) + { + var values = Enum.GetValues(type); + return values.GetValue(Random.Next(values.Length)); + } + + // Unsupported type + return null; + } + + private static string GenerateRandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[Random.Next(s.Length)]).ToArray()); + } +} diff --git a/API.Tests/Services/ImageServiceTests.cs b/API.Tests/Services/ImageServiceTests.cs index a1073a55b..f2c87e1ad 100644 --- a/API.Tests/Services/ImageServiceTests.cs +++ b/API.Tests/Services/ImageServiceTests.cs @@ -161,10 +161,10 @@ public class ImageServiceTests private static void GenerateColorImage(string hexColor, string outputPath) { - var color = ImageService.HexToRgb(hexColor); - using var colorImage = Image.Black(200, 100); - using var output = colorImage + new[] { color.R / 255.0, color.G / 255.0, color.B / 255.0 }; - output.WriteToFile(outputPath); + var (r, g, b) = ImageService.HexToRgb(hexColor); + using var blackImage = Image.Black(200, 100); + using var colorImage = blackImage.NewFromImage(r, g, b); + colorImage.WriteToFile(outputPath); } private void GenerateHtmlFileForColorScape() diff --git a/API.Tests/Services/ReadingProfileServiceTest.cs b/API.Tests/Services/ReadingProfileServiceTest.cs new file mode 100644 index 000000000..b3d81e5ac --- /dev/null +++ b/API.Tests/Services/ReadingProfileServiceTest.cs @@ -0,0 +1,561 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Helpers.Builders; +using API.Services; +using API.Tests.Helpers; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class ReadingProfileServiceTest: AbstractDbTest +{ + + /// + /// Does not add a default reading profile + /// + /// + public async Task<(ReadingProfileService, AppUser, Library, Series)> Setup() + { + var user = new AppUserBuilder("amelia", "amelia@localhost").Build(); + Context.AppUser.Add(user); + await UnitOfWork.CommitAsync(); + + var series = new SeriesBuilder("Spice and Wolf").Build(); + + var library = new LibraryBuilder("Manga") + .WithSeries(series) + .Build(); + + user.Libraries.Add(library); + await UnitOfWork.CommitAsync(); + + var rps = new ReadingProfileService(UnitOfWork, Substitute.For(), Mapper); + user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.UserPreferences); + + return (rps, user, library, series); + } + + [Fact] + public async Task ImplicitProfileFirst() + { + await ResetDb(); + var (rps, user, library, series) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Implicit) + .WithSeries(series) + .WithName("Implicit Profile") + .Build(); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Non-implicit Profile") + .Build(); + + user.ReadingProfiles.Add(profile); + user.ReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal("Implicit Profile", seriesProfile.Name); + + // Find parent + seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id, true); + Assert.NotNull(seriesProfile); + Assert.Equal("Non-implicit Profile", seriesProfile.Name); + } + + [Fact] + public async Task CantDeleteDefaultReadingProfile() + { + await ResetDb(); + var (rps, user, _, _) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Default) + .Build(); + Context.AppUserReadingProfiles.Add(profile); + await UnitOfWork.CommitAsync(); + + await Assert.ThrowsAsync(async () => + { + await rps.DeleteReadingProfile(user.Id, profile.Id); + }); + + var profile2 = new AppUserReadingProfileBuilder(user.Id).Build(); + Context.AppUserReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + await rps.DeleteReadingProfile(user.Id, profile2.Id); + await UnitOfWork.CommitAsync(); + + var allProfiles = await Context.AppUserReadingProfiles.ToListAsync(); + Assert.Single(allProfiles); + } + + [Fact] + public async Task CreateImplicitSeriesReadingProfile() + { + await ResetDb(); + var (rps, user, _, series) = await Setup(); + + var dto = new UserReadingProfileDto + { + ReaderMode = ReaderMode.Webtoon, + ScalingOption = ScalingOption.FitToHeight, + WidthOverride = 53, + }; + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto); + + var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.SeriesIds, s => s == series.Id); + Assert.Equal(ReadingProfileKind.Implicit, profile.Kind); + } + + [Fact] + public async Task UpdateImplicitReadingProfile_DoesNotCreateNew() + { + await ResetDb(); + var (rps, user, _, series) = await Setup(); + + var dto = new UserReadingProfileDto + { + ReaderMode = ReaderMode.Webtoon, + ScalingOption = ScalingOption.FitToHeight, + WidthOverride = 53, + }; + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto); + + var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.SeriesIds, s => s == series.Id); + Assert.Equal(ReadingProfileKind.Implicit, profile.Kind); + + dto = new UserReadingProfileDto + { + ReaderMode = ReaderMode.LeftRight, + }; + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto); + profile = await rps.GetReadingProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.SeriesIds, s => s == series.Id); + Assert.Equal(ReadingProfileKind.Implicit, profile.Kind); + Assert.Equal(ReaderMode.LeftRight, profile.ReaderMode); + + var implicitCount = await Context.AppUserReadingProfiles + .Where(p => p.Kind == ReadingProfileKind.Implicit) + .CountAsync(); + Assert.Equal(1, implicitCount); + } + + [Fact] + public async Task GetCorrectProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Series Specific") + .Build(); + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithLibrary(lib) + .WithName("Library Specific") + .Build(); + var profile3 = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Default) + .WithName("Global") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + Context.AppUserReadingProfiles.Add(profile2); + Context.AppUserReadingProfiles.Add(profile3); + + var series2 = new SeriesBuilder("Rainbows After Storms").Build(); + lib.Series.Add(series2); + + var lib2 = new LibraryBuilder("Manga2").Build(); + var series3 = new SeriesBuilder("A Tropical Fish Yearns for Snow").Build(); + lib2.Series.Add(series3); + + user.Libraries.Add(lib2); + await UnitOfWork.CommitAsync(); + + var p = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(p); + Assert.Equal("Series Specific", p.Name); + + p = await rps.GetReadingProfileDtoForSeries(user.Id, series2.Id); + Assert.NotNull(p); + Assert.Equal("Library Specific", p.Name); + + p = await rps.GetReadingProfileDtoForSeries(user.Id, series3.Id); + Assert.NotNull(p); + Assert.Equal("Global", p.Name); + } + + [Fact] + public async Task ReplaceReadingProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var profile1 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile 1") + .Build(); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithName("Profile 2") + .Build(); + + Context.AppUserReadingProfiles.Add(profile1); + Context.AppUserReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + var profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Equal("Profile 1", profile.Name); + + await rps.AddProfileToSeries(user.Id, profile2.Id, series.Id); + profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Equal("Profile 2", profile.Name); + } + + [Fact] + public async Task DeleteReadingProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var profile1 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile 1") + .Build(); + + Context.AppUserReadingProfiles.Add(profile1); + await UnitOfWork.CommitAsync(); + + await rps.ClearSeriesProfile(user.Id, series.Id); + var profiles = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id); + Assert.DoesNotContain(profiles, rp => rp.SeriesIds.Contains(series.Id)); + + } + + [Fact] + public async Task BulkAddReadingProfiles() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + for (var i = 0; i < 10; i++) + { + var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build(); + lib.Series.Add(generatedSeries); + } + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Profile2") + .Build(); + Context.AppUserReadingProfiles.Add(profile2); + + await UnitOfWork.CommitAsync(); + + var someSeriesIds = lib.Series.Take(lib.Series.Count / 2).Select(s => s.Id).ToList(); + await rps.BulkAddProfileToSeries(user.Id, profile.Id, someSeriesIds); + + foreach (var id in someSeriesIds) + { + var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(foundProfile); + Assert.Equal(profile.Id, foundProfile.Id); + } + + var allIds = lib.Series.Select(s => s.Id).ToList(); + await rps.BulkAddProfileToSeries(user.Id, profile2.Id, allIds); + + foreach (var id in allIds) + { + var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(foundProfile); + Assert.Equal(profile2.Id, foundProfile.Id); + } + + + } + + [Fact] + public async Task BulkAssignDeletesImplicit() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var implicitProfile = Mapper.Map(new AppUserReadingProfileBuilder(user.Id) + .Build()); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Profile 1") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + + for (var i = 0; i < 10; i++) + { + var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build(); + lib.Series.Add(generatedSeries); + } + await UnitOfWork.CommitAsync(); + + var ids = lib.Series.Select(s => s.Id).ToList(); + + foreach (var id in ids) + { + await rps.UpdateImplicitReadingProfile(user.Id, id, implicitProfile); + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind); + } + + await rps.BulkAddProfileToSeries(user.Id, profile.Id, ids); + + foreach (var id in ids) + { + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind); + } + + var implicitCount = await Context.AppUserReadingProfiles + .Where(p => p.Kind == ReadingProfileKind.Implicit) + .CountAsync(); + Assert.Equal(0, implicitCount); + } + + [Fact] + public async Task AddDeletesImplicit() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var implicitProfile = Mapper.Map(new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Implicit) + .Build()); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Profile 1") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + await UnitOfWork.CommitAsync(); + + await rps.UpdateImplicitReadingProfile(user.Id, series.Id, implicitProfile); + + var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind); + + await rps.AddProfileToSeries(user.Id, profile.Id, series.Id); + + seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind); + + var implicitCount = await Context.AppUserReadingProfiles + .Where(p => p.Kind == ReadingProfileKind.Implicit) + .CountAsync(); + Assert.Equal(0, implicitCount); + } + + [Fact] + public async Task CreateReadingProfile() + { + await ResetDb(); + var (rps, user, lib, series) = await Setup(); + + var dto = new UserReadingProfileDto + { + Name = "Profile 1", + ReaderMode = ReaderMode.LeftRight, + EmulateBook = false, + }; + + await rps.CreateReadingProfile(user.Id, dto); + + var dto2 = new UserReadingProfileDto + { + Name = "Profile 2", + ReaderMode = ReaderMode.LeftRight, + EmulateBook = false, + }; + + await rps.CreateReadingProfile(user.Id, dto2); + + var dto3 = new UserReadingProfileDto + { + Name = "Profile 1", // Not unique name + ReaderMode = ReaderMode.LeftRight, + EmulateBook = false, + }; + + await Assert.ThrowsAsync(async () => + { + await rps.CreateReadingProfile(user.Id, dto3); + }); + + var allProfiles = Context.AppUserReadingProfiles.ToList(); + Assert.Equal(2, allProfiles.Count); + } + + [Fact] + public async Task ClearSeriesProfile_RemovesImplicitAndUnlinksExplicit() + { + await ResetDb(); + var (rps, user, _, series) = await Setup(); + + var implicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithKind(ReadingProfileKind.Implicit) + .WithName("Implicit Profile") + .Build(); + + var explicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Explicit Profile") + .Build(); + + Context.AppUserReadingProfiles.Add(implicitProfile); + Context.AppUserReadingProfiles.Add(explicitProfile); + await UnitOfWork.CommitAsync(); + + var allBefore = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id); + Assert.Equal(2, allBefore.Count(rp => rp.SeriesIds.Contains(series.Id))); + + await rps.ClearSeriesProfile(user.Id, series.Id); + + var remainingProfiles = await Context.AppUserReadingProfiles.ToListAsync(); + Assert.Single(remainingProfiles); + Assert.Equal("Explicit Profile", remainingProfiles[0].Name); + Assert.Empty(remainingProfiles[0].SeriesIds); + + var profilesForSeries = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id); + Assert.DoesNotContain(profilesForSeries, rp => rp.SeriesIds.Contains(series.Id)); + } + + [Fact] + public async Task AddProfileToLibrary_AddsAndOverridesExisting() + { + await ResetDb(); + var (rps, user, lib, _) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Library Profile") + .Build(); + Context.AppUserReadingProfiles.Add(profile); + await UnitOfWork.CommitAsync(); + + await rps.AddProfileToLibrary(user.Id, profile.Id, lib.Id); + await UnitOfWork.CommitAsync(); + + var linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.NotNull(linkedProfile); + Assert.Equal(profile.Id, linkedProfile.Id); + + var newProfile = new AppUserReadingProfileBuilder(user.Id) + .WithName("New Profile") + .Build(); + Context.AppUserReadingProfiles.Add(newProfile); + await UnitOfWork.CommitAsync(); + + await rps.AddProfileToLibrary(user.Id, newProfile.Id, lib.Id); + await UnitOfWork.CommitAsync(); + + linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.NotNull(linkedProfile); + Assert.Equal(newProfile.Id, linkedProfile.Id); + } + + [Fact] + public async Task ClearLibraryProfile_RemovesImplicitOrUnlinksExplicit() + { + await ResetDb(); + var (rps, user, lib, _) = await Setup(); + + var implicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithKind(ReadingProfileKind.Implicit) + .WithLibrary(lib) + .Build(); + Context.AppUserReadingProfiles.Add(implicitProfile); + await UnitOfWork.CommitAsync(); + + await rps.ClearLibraryProfile(user.Id, lib.Id); + var profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.Null(profile); + + var explicitProfile = new AppUserReadingProfileBuilder(user.Id) + .WithLibrary(lib) + .Build(); + Context.AppUserReadingProfiles.Add(explicitProfile); + await UnitOfWork.CommitAsync(); + + await rps.ClearLibraryProfile(user.Id, lib.Id); + profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id)) + .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id)); + Assert.Null(profile); + + var stillExists = await Context.AppUserReadingProfiles.FindAsync(explicitProfile.Id); + Assert.NotNull(stillExists); + } + + /// + /// As response to #3793, I'm not sure if we want to keep this. It's not the most nice. But I think the idea of this test + /// is worth having. + /// + [Fact] + public void UpdateFields_UpdatesAll() + { + // Repeat to ensure booleans are flipped and actually tested + for (int i = 0; i < 10; i++) + { + var profile = new AppUserReadingProfile(); + var dto = new UserReadingProfileDto(); + + RandfHelper.SetRandomValues(profile); + RandfHelper.SetRandomValues(dto); + + ReadingProfileService.UpdateReaderProfileFields(profile, dto); + + var newDto = Mapper.Map(profile); + + Assert.True(RandfHelper.AreSimpleFieldsEqual(dto, newDto, + ["k__BackingField", "k__BackingField"])); + } + } + + + + protected override async Task ResetDb() + { + Context.AppUserReadingProfiles.RemoveRange(Context.AppUserReadingProfiles); + await UnitOfWork.CommitAsync(); + } +} diff --git a/API/API.csproj b/API/API.csproj index f9a889d74..4eed66f22 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -97,9 +97,9 @@ + - diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index c504e1ce7..d8b9164af 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -153,6 +153,9 @@ public class AccountController : BaseApiController // Assign default streams AddDefaultStreamsToUser(user); + // Assign default reading profile + await AddDefaultReadingProfileToUser(user); + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen")); if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Get("en", "validate-email", token)); @@ -609,7 +612,7 @@ public class AccountController : BaseApiController } /// - /// Requests the Invite Url for the UserId. Will return error if user is already validated. + /// Requests the Invite Url for the AppUserId. Will return error if user is already validated. /// /// /// Include the "https://ip:port/" in the generated link @@ -669,6 +672,9 @@ public class AccountController : BaseApiController // Assign default streams AddDefaultStreamsToUser(user); + // Assign default reading profile + await AddDefaultReadingProfileToUser(user); + // Assign Roles var roles = dto.Roles; var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); @@ -779,6 +785,16 @@ public class AccountController : BaseApiController } } + private async Task AddDefaultReadingProfileToUser(AppUser user) + { + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithName("Default Profile") + .WithKind(ReadingProfileKind.Default) + .Build(); + _unitOfWork.AppUserReadingProfileRepository.Add(profile); + await _unitOfWork.CommitAsync(); + } + /// /// Last step in authentication flow, confirms the email token for email /// diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 10a5f393a..cab33692a 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -6,8 +6,10 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; +using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; @@ -46,6 +48,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context)); } + /// + /// Returns a list of Genres with counts for counts when Genre is on Series/Chapter + /// + /// + [HttpPost("genres-with-counts")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)] + public async Task>> GetBrowseGenres(UserParams? userParams = null) + { + userParams ??= UserParams.Default; + + var list = await unitOfWork.GenreRepository.GetBrowseableGenre(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + + return Ok(list); + } + /// /// Fetches people from the instance by role /// @@ -95,6 +113,22 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId())); } + /// + /// Returns a list of Tags with counts for counts when Tag is on Series/Chapter + /// + /// + [HttpPost("tags-with-counts")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)] + public async Task>> GetBrowseTags(UserParams? userParams = null) + { + userParams ??= UserParams.Default; + + var list = await unitOfWork.TagRepository.GetBrowseableTag(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + + return Ok(list); + } + /// /// Fetches all age ratings from the instance /// diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index a2ab3bf88..bf3cc1814 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -4,6 +4,9 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; using API.DTOs.Person; using API.Entities.Enums; using API.Extensions; @@ -77,11 +80,13 @@ public class PersonController : BaseApiController /// /// [HttpPost("all")] - public async Task>> GetAuthorsForBrowse([FromQuery] UserParams? userParams) + public async Task>> GetPeopleForBrowse(BrowsePersonFilterDto filter, [FromQuery] UserParams? userParams) { userParams ??= UserParams.Default; - var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams); + + var list = await _unitOfWork.PersonRepository.GetBrowsePersonDtos(User.GetUserId(), filter, userParams); Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + return Ok(list); } @@ -112,6 +117,7 @@ public class PersonController : BaseApiController person.Name = dto.Name?.Trim(); + person.NormalizedName = person.Name.ToNormalized(); person.Description = dto.Description ?? string.Empty; person.CoverImageLocked = dto.CoverImageLocked; diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index c7f48cf54..f39462bbf 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -45,7 +45,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService throw new KavitaUnauthenticatedUserException(); } var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId); - logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); + logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({AppUserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); return new UserDto { diff --git a/API/Controllers/ReadingProfileController.cs b/API/Controllers/ReadingProfileController.cs new file mode 100644 index 000000000..bc1b4fa52 --- /dev/null +++ b/API/Controllers/ReadingProfileController.cs @@ -0,0 +1,198 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Extensions; +using API.Services; +using AutoMapper; +using Kavita.Common; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Controllers; + +[Route("api/reading-profile")] +public class ReadingProfileController(ILogger logger, IUnitOfWork unitOfWork, + IReadingProfileService readingProfileService): BaseApiController +{ + + /// + /// Gets all non-implicit reading profiles for a user + /// + /// + [HttpGet("all")] + public async Task>> GetAllReadingProfiles() + { + return Ok(await unitOfWork.AppUserReadingProfileRepository.GetProfilesDtoForUser(User.GetUserId(), true)); + } + + /// + /// Returns the ReadingProfile that should be applied to the given series, walks up the tree. + /// Series -> Library -> Default + /// + /// + /// + /// + [HttpGet("{seriesId:int}")] + public async Task> GetProfileForSeries(int seriesId, [FromQuery] bool skipImplicit) + { + return Ok(await readingProfileService.GetReadingProfileDtoForSeries(User.GetUserId(), seriesId, skipImplicit)); + } + + /// + /// Returns the (potential) Reading Profile bound to the library + /// + /// + /// + [HttpGet("library")] + public async Task> GetProfileForLibrary(int libraryId) + { + return Ok(await readingProfileService.GetReadingProfileDtoForLibrary(User.GetUserId(), libraryId)); + } + + /// + /// Creates a new reading profile for the current user + /// + /// + /// + [HttpPost("create")] + public async Task> CreateReadingProfile([FromBody] UserReadingProfileDto dto) + { + return Ok(await readingProfileService.CreateReadingProfile(User.GetUserId(), dto)); + } + + /// + /// Promotes the implicit profile to a user profile. Removes the series from other profiles + /// + /// + /// + [HttpPost("promote")] + public async Task> PromoteImplicitReadingProfile([FromQuery] int profileId) + { + return Ok(await readingProfileService.PromoteImplicitProfile(User.GetUserId(), profileId)); + } + + /// + /// Update the implicit reading profile for a series, creates one if none exists + /// + /// Any modification to the reader settings during reading will create an implicit profile. Use "update-parent" to save to the bound series profile. + /// + /// + /// + [HttpPost("series")] + public async Task> UpdateReadingProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId) + { + var updatedProfile = await readingProfileService.UpdateImplicitReadingProfile(User.GetUserId(), seriesId, dto); + return Ok(updatedProfile); + } + + /// + /// Updates the non-implicit reading profile for the given series, and removes implicit profiles + /// + /// + /// + /// + [HttpPost("update-parent")] + public async Task> UpdateParentProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId) + { + var newParentProfile = await readingProfileService.UpdateParent(User.GetUserId(), seriesId, dto); + return Ok(newParentProfile); + } + + /// + /// Updates the given reading profile, must belong to the current user + /// + /// + /// The updated reading profile + /// + /// This does not update connected series and libraries. + /// + [HttpPost] + public async Task> UpdateReadingProfile(UserReadingProfileDto dto) + { + return Ok(await readingProfileService.UpdateReadingProfile(User.GetUserId(), dto)); + } + + /// + /// Deletes the given profile, requires the profile to belong to the logged-in user + /// + /// + /// + /// + /// + [HttpDelete] + public async Task DeleteReadingProfile([FromQuery] int profileId) + { + await readingProfileService.DeleteReadingProfile(User.GetUserId(), profileId); + return Ok(); + } + + /// + /// Sets the reading profile for a given series, removes the old one + /// + /// + /// + /// + [HttpPost("series/{seriesId:int}")] + public async Task AddProfileToSeries(int seriesId, [FromQuery] int profileId) + { + await readingProfileService.AddProfileToSeries(User.GetUserId(), profileId, seriesId); + return Ok(); + } + + /// + /// Clears the reading profile for the given series for the currently logged-in user + /// + /// + /// + [HttpDelete("series/{seriesId:int}")] + public async Task ClearSeriesProfile(int seriesId) + { + await readingProfileService.ClearSeriesProfile(User.GetUserId(), seriesId); + return Ok(); + } + + /// + /// Sets the reading profile for a given library, removes the old one + /// + /// + /// + /// + [HttpPost("library/{libraryId:int}")] + public async Task AddProfileToLibrary(int libraryId, [FromQuery] int profileId) + { + await readingProfileService.AddProfileToLibrary(User.GetUserId(), profileId, libraryId); + return Ok(); + } + + /// + /// Clears the reading profile for the given library for the currently logged-in user + /// + /// + /// + /// + [HttpDelete("library/{libraryId:int}")] + public async Task ClearLibraryProfile(int libraryId) + { + await readingProfileService.ClearLibraryProfile(User.GetUserId(), libraryId); + return Ok(); + } + + /// + /// Assigns the reading profile to all passes series, and deletes their implicit profiles + /// + /// + /// + /// + [HttpPost("bulk")] + public async Task BulkAddReadingProfile([FromQuery] int profileId, [FromBody] IList seriesIds) + { + await readingProfileService.BulkAddProfileToSeries(User.GetUserId(), profileId, seriesIds); + return Ok(); + } + +} diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 7cd897c32..84eacc838 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -310,7 +310,7 @@ public class SeriesController : BaseApiController /// /// /// - /// + /// This is not in use /// [HttpPost("all-v2")] public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, @@ -321,8 +321,6 @@ public class SeriesController : BaseApiController await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context); // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 944ea987b..17ebc758e 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -103,38 +103,13 @@ public class UsersController : BaseApiController var existingPreferences = user!.UserPreferences; - existingPreferences.ReadingDirection = preferencesDto.ReadingDirection; - existingPreferences.ScalingOption = preferencesDto.ScalingOption; - existingPreferences.PageSplitOption = preferencesDto.PageSplitOption; - existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu; - existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints; - existingPreferences.EmulateBook = preferencesDto.EmulateBook; - existingPreferences.ReaderMode = preferencesDto.ReaderMode; - existingPreferences.LayoutMode = preferencesDto.LayoutMode; - existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor; - existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin; - existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing; - existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily; - existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; - existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; - existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; - existingPreferences.BookReaderWritingStyle = preferencesDto.BookReaderWritingStyle; - existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName; - existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode; - existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode; existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode; existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries; - existingPreferences.LayoutMode = preferencesDto.LayoutMode; existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; existingPreferences.NoTransitions = preferencesDto.NoTransitions; - existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; - existingPreferences.PdfTheme = preferencesDto.PdfTheme; - existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode; - existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode; - if (await _licenseService.HasActiveLicense()) { existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled; diff --git a/API/DTOs/Filtering/PersonSortField.cs b/API/DTOs/Filtering/PersonSortField.cs new file mode 100644 index 000000000..5268a1bf9 --- /dev/null +++ b/API/DTOs/Filtering/PersonSortField.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Filtering; + +public enum PersonSortField +{ + Name = 1, + SeriesCount = 2, + ChapterCount = 3 +} diff --git a/API/DTOs/Filtering/SortOptions.cs b/API/DTOs/Filtering/SortOptions.cs index a08e2968e..18f2b17ea 100644 --- a/API/DTOs/Filtering/SortOptions.cs +++ b/API/DTOs/Filtering/SortOptions.cs @@ -8,3 +8,12 @@ public sealed record SortOptions public SortField SortField { get; set; } public bool IsAscending { get; set; } = true; } + +/// +/// All Sorting Options for a query related to Person Entity +/// +public sealed record PersonSortOptions +{ + public PersonSortField SortField { get; set; } + public bool IsAscending { get; set; } = true; +} diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index 5323f2b48..246a92a90 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -56,5 +56,12 @@ public enum FilterField /// Last time User Read /// ReadLast = 32, - +} + +public enum PersonFilterField +{ + Role = 1, + Name = 2, + SeriesCount = 3, + ChapterCount = 4, } diff --git a/API/DTOs/Filtering/v2/FilterStatementDto.cs b/API/DTOs/Filtering/v2/FilterStatementDto.cs index ebe6d16af..8c99bd24c 100644 --- a/API/DTOs/Filtering/v2/FilterStatementDto.cs +++ b/API/DTOs/Filtering/v2/FilterStatementDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs.Filtering.v2; +using API.DTOs.Metadata.Browse.Requests; + +namespace API.DTOs.Filtering.v2; public sealed record FilterStatementDto { @@ -6,3 +8,10 @@ public sealed record FilterStatementDto public FilterField Field { get; set; } public string Value { get; set; } } + +public sealed record PersonFilterStatementDto +{ + public FilterComparison Comparison { get; set; } + public PersonFilterField Field { get; set; } + public string Value { get; set; } +} diff --git a/API/DTOs/Filtering/v2/FilterV2Dto.cs b/API/DTOs/Filtering/v2/FilterV2Dto.cs index 11dc42a6b..a247a17a6 100644 --- a/API/DTOs/Filtering/v2/FilterV2Dto.cs +++ b/API/DTOs/Filtering/v2/FilterV2Dto.cs @@ -16,7 +16,7 @@ public sealed record FilterV2Dto /// The name of the filter /// public string? Name { get; set; } - public ICollection Statements { get; set; } = new List(); + public ICollection Statements { get; set; } = []; public FilterCombination Combination { get; set; } = FilterCombination.And; public SortOptions? SortOptions { get; set; } diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs index 8eb38c98a..c394cf8d4 100644 --- a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs @@ -15,5 +15,9 @@ public enum MatchStateOption public sealed record ManageMatchFilterDto { public MatchStateOption MatchStateOption { get; set; } = MatchStateOption.All; + /// + /// Library Type in int form. -1 indicates to ignore the field. + /// + public int LibraryType { get; set; } = -1; public string SearchTerm { get; set; } = string.Empty; } diff --git a/API/DTOs/Metadata/Browse/BrowseGenreDto.cs b/API/DTOs/Metadata/Browse/BrowseGenreDto.cs new file mode 100644 index 000000000..8044c7914 --- /dev/null +++ b/API/DTOs/Metadata/Browse/BrowseGenreDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Metadata.Browse; + +public sealed record BrowseGenreDto : GenreTagDto +{ + /// + /// Number of Series this Entity is on + /// + public int SeriesCount { get; set; } + /// + /// Number of Chapters this Entity is on + /// + public int ChapterCount { get; set; } +} diff --git a/API/DTOs/Person/BrowsePersonDto.cs b/API/DTOs/Metadata/Browse/BrowsePersonDto.cs similarity index 71% rename from API/DTOs/Person/BrowsePersonDto.cs rename to API/DTOs/Metadata/Browse/BrowsePersonDto.cs index c7d318e79..20f84b783 100644 --- a/API/DTOs/Person/BrowsePersonDto.cs +++ b/API/DTOs/Metadata/Browse/BrowsePersonDto.cs @@ -1,6 +1,6 @@ using API.DTOs.Person; -namespace API.DTOs; +namespace API.DTOs.Metadata.Browse; /// /// Used to browse writers and click in to see their series @@ -12,7 +12,7 @@ public class BrowsePersonDto : PersonDto /// public int SeriesCount { get; set; } /// - /// Number or Issues this Person is the Writer for + /// Number of Issues this Person is the Writer for /// - public int IssueCount { get; set; } + public int ChapterCount { get; set; } } diff --git a/API/DTOs/Metadata/Browse/BrowseTagDto.cs b/API/DTOs/Metadata/Browse/BrowseTagDto.cs new file mode 100644 index 000000000..9a71876e3 --- /dev/null +++ b/API/DTOs/Metadata/Browse/BrowseTagDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Metadata.Browse; + +public sealed record BrowseTagDto : TagDto +{ + /// + /// Number of Series this Entity is on + /// + public int SeriesCount { get; set; } + /// + /// Number of Chapters this Entity is on + /// + public int ChapterCount { get; set; } +} diff --git a/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs new file mode 100644 index 000000000..d41cf37f3 --- /dev/null +++ b/API/DTOs/Metadata/Browse/Requests/BrowsePersonFilterDto.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using API.DTOs.Filtering; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; + +namespace API.DTOs.Metadata.Browse.Requests; +#nullable enable + +public sealed record BrowsePersonFilterDto +{ + /// + /// Not used - For parity with Series Filter + /// + public int Id { get; set; } + /// + /// Not used - For parity with Series Filter + /// + public string? Name { get; set; } + public ICollection Statements { get; set; } = []; + public FilterCombination Combination { get; set; } = FilterCombination.And; + public PersonSortOptions? SortOptions { get; set; } + + /// + /// Limit the number of rows returned. Defaults to not applying a limit (aka 0) + /// + public int LimitTo { get; set; } = 0; +} diff --git a/API/DTOs/Metadata/GenreTagDto.cs b/API/DTOs/Metadata/GenreTagDto.cs index 4846048d2..13a339d38 100644 --- a/API/DTOs/Metadata/GenreTagDto.cs +++ b/API/DTOs/Metadata/GenreTagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public sealed record GenreTagDto +public record GenreTagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/Metadata/TagDto.cs b/API/DTOs/Metadata/TagDto.cs index f8deb6913..f5c925e1f 100644 --- a/API/DTOs/Metadata/TagDto.cs +++ b/API/DTOs/Metadata/TagDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Metadata; -public sealed record TagDto +public record TagDto { public int Id { get; set; } public required string Title { get; set; } diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index cbc16275d..47a526411 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -49,6 +49,11 @@ public sealed record ReadingListDto : IHasCoverImage /// public required AgeRating AgeRating { get; set; } = AgeRating.Unknown; + /// + /// Username of the User that owns (in the case of a promoted list) + /// + public string OwnerUserName { get; set; } + public void ResetColorScape() { PrimaryColor = string.Empty; diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 6645a8f39..46f42306e 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -9,61 +9,6 @@ namespace API.DTOs; public sealed record UserPreferencesDto { - /// - [Required] - public ReadingDirection ReadingDirection { get; set; } - /// - [Required] - public ScalingOption ScalingOption { get; set; } - /// - [Required] - public PageSplitOption PageSplitOption { get; set; } - /// - [Required] - public ReaderMode ReaderMode { get; set; } - /// - [Required] - public LayoutMode LayoutMode { get; set; } - /// - [Required] - public bool EmulateBook { get; set; } - /// - [Required] - public string BackgroundColor { get; set; } = "#000000"; - /// - [Required] - public bool SwipeToPaginate { get; set; } - /// - [Required] - public bool AutoCloseMenu { get; set; } - /// - [Required] - public bool ShowScreenHints { get; set; } = true; - /// - [Required] - public bool AllowAutomaticWebtoonReaderDetection { get; set; } - - /// - [Required] - public int BookReaderMargin { get; set; } - /// - [Required] - public int BookReaderLineSpacing { get; set; } - /// - [Required] - public int BookReaderFontSize { get; set; } - /// - [Required] - public string BookReaderFontFamily { get; set; } = null!; - /// - [Required] - public bool BookReaderTapToPaginate { get; set; } - /// - [Required] - public ReadingDirection BookReaderReadingDirection { get; set; } - /// - [Required] - public WritingStyle BookReaderWritingStyle { get; set; } /// /// UI Site Global Setting: The UI theme the user should use. @@ -72,15 +17,6 @@ public sealed record UserPreferencesDto [Required] public SiteThemeDto? Theme { get; set; } - [Required] public string BookReaderThemeName { get; set; } = null!; - /// - [Required] - public BookPageLayoutMode BookReaderLayoutMode { get; set; } - /// - [Required] - public bool BookReaderImmersiveMode { get; set; } = false; - /// - [Required] public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; /// [Required] @@ -101,16 +37,6 @@ public sealed record UserPreferencesDto [Required] public string Locale { get; set; } - /// - [Required] - public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; - /// - [Required] - public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; - /// - [Required] - public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; - /// public bool AniListScrobblingEnabled { get; set; } /// diff --git a/API/DTOs/UserReadingProfileDto.cs b/API/DTOs/UserReadingProfileDto.cs new file mode 100644 index 000000000..24dbf1c34 --- /dev/null +++ b/API/DTOs/UserReadingProfileDto.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Enums.UserPreferences; + +namespace API.DTOs; + +public sealed record UserReadingProfileDto +{ + + public int Id { get; set; } + public int UserId { get; init; } + + public string Name { get; init; } + public ReadingProfileKind Kind { get; init; } + + #region MangaReader + + /// + [Required] + public ReadingDirection ReadingDirection { get; set; } + + /// + [Required] + public ScalingOption ScalingOption { get; set; } + + /// + [Required] + public PageSplitOption PageSplitOption { get; set; } + + /// + [Required] + public ReaderMode ReaderMode { get; set; } + + /// + [Required] + public bool AutoCloseMenu { get; set; } + + /// + [Required] + public bool ShowScreenHints { get; set; } = true; + + /// + [Required] + public bool EmulateBook { get; set; } + + /// + [Required] + public LayoutMode LayoutMode { get; set; } + + /// + [Required] + public string BackgroundColor { get; set; } = "#000000"; + + /// + [Required] + public bool SwipeToPaginate { get; set; } + + /// + [Required] + public bool AllowAutomaticWebtoonReaderDetection { get; set; } + + /// + public int? WidthOverride { get; set; } + + /// + public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never; + + #endregion + + #region EpubReader + + /// + [Required] + public int BookReaderMargin { get; set; } + + /// + [Required] + public int BookReaderLineSpacing { get; set; } + + /// + [Required] + public int BookReaderFontSize { get; set; } + + /// + [Required] + public string BookReaderFontFamily { get; set; } = null!; + + /// + [Required] + public bool BookReaderTapToPaginate { get; set; } + + /// + [Required] + public ReadingDirection BookReaderReadingDirection { get; set; } + + /// + [Required] + public WritingStyle BookReaderWritingStyle { get; set; } + + /// + [Required] + public string BookReaderThemeName { get; set; } = null!; + + /// + [Required] + public BookPageLayoutMode BookReaderLayoutMode { get; set; } + + /// + [Required] + public bool BookReaderImmersiveMode { get; set; } = false; + + #endregion + + #region PdfReader + + /// + [Required] + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + + /// + [Required] + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + + /// + [Required] + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; + + #endregion + +} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index ce35ba7ec..3bbf45e23 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -81,6 +81,7 @@ public sealed class DataContext : IdentityDbContext MetadataSettings { get; set; } = null!; public DbSet MetadataFieldMapping { get; set; } = null!; public DbSet AppUserChapterRating { get; set; } = null!; + public DbSet AppUserReadingProfiles { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { @@ -256,6 +257,32 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.EnableCoverImage) .HasDefaultValue(true); + + builder.Entity() + .Property(b => b.BookThemeName) + .HasDefaultValue("Dark"); + builder.Entity() + .Property(b => b.BackgroundColor) + .HasDefaultValue("#000000"); + builder.Entity() + .Property(b => b.BookReaderWritingStyle) + .HasDefaultValue(WritingStyle.Horizontal); + builder.Entity() + .Property(b => b.AllowAutomaticWebtoonReaderDetection) + .HasDefaultValue(true); + + builder.Entity() + .Property(rp => rp.LibraryIds) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasColumnType("TEXT"); + builder.Entity() + .Property(rp => rp.SeriesIds) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List()) + .HasColumnType("TEXT"); } #nullable enable diff --git a/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs new file mode 100644 index 000000000..b2afde98a --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.Enums; +using API.Entities.History; +using API.Extensions; +using API.Helpers.Builders; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +public static class ManualMigrateReadingProfiles +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateReadingProfiles")) + { + return; + } + + logger.LogCritical("Running ManualMigrateReadingProfiles migration - Please be patient, this may take some time. This is not an error"); + + var users = await context.AppUser + .Include(u => u.UserPreferences) + .Include(u => u.ReadingProfiles) + .ToListAsync(); + + foreach (var user in users) + { + var readingProfile = new AppUserReadingProfile + { + Name = "Default", + NormalizedName = "Default".ToNormalized(), + Kind = ReadingProfileKind.Default, + LibraryIds = [], + SeriesIds = [], + BackgroundColor = user.UserPreferences.BackgroundColor, + EmulateBook = user.UserPreferences.EmulateBook, + AppUser = user, + PdfTheme = user.UserPreferences.PdfTheme, + ReaderMode = user.UserPreferences.ReaderMode, + ReadingDirection = user.UserPreferences.ReadingDirection, + ScalingOption = user.UserPreferences.ScalingOption, + LayoutMode = user.UserPreferences.LayoutMode, + WidthOverride = null, + AppUserId = user.Id, + AutoCloseMenu = user.UserPreferences.AutoCloseMenu, + BookReaderMargin = user.UserPreferences.BookReaderMargin, + PageSplitOption = user.UserPreferences.PageSplitOption, + BookThemeName = user.UserPreferences.BookThemeName, + PdfSpreadMode = user.UserPreferences.PdfSpreadMode, + PdfScrollMode = user.UserPreferences.PdfScrollMode, + SwipeToPaginate = user.UserPreferences.SwipeToPaginate, + BookReaderFontFamily = user.UserPreferences.BookReaderFontFamily, + BookReaderFontSize = user.UserPreferences.BookReaderFontSize, + BookReaderImmersiveMode = user.UserPreferences.BookReaderImmersiveMode, + BookReaderLayoutMode = user.UserPreferences.BookReaderLayoutMode, + BookReaderLineSpacing = user.UserPreferences.BookReaderLineSpacing, + BookReaderReadingDirection = user.UserPreferences.BookReaderReadingDirection, + BookReaderWritingStyle = user.UserPreferences.BookReaderWritingStyle, + AllowAutomaticWebtoonReaderDetection = user.UserPreferences.AllowAutomaticWebtoonReaderDetection, + BookReaderTapToPaginate = user.UserPreferences.BookReaderTapToPaginate, + ShowScreenHints = user.UserPreferences.ShowScreenHints, + }; + user.ReadingProfiles.Add(readingProfile); + } + + await context.SaveChangesAsync(); + + context.ManualMigrationHistory.Add(new ManualMigrationHistory + { + Name = "ManualMigrateReadingProfiles", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow, + }); + await context.SaveChangesAsync(); + + + logger.LogCritical("Running ManualMigrateReadingProfiles migration - Completed. This is not an error"); + + } +} diff --git a/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs b/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs new file mode 100644 index 000000000..762eae142 --- /dev/null +++ b/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs @@ -0,0 +1,3698 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250601200056_ReadingProfiles")] + partial class ReadingProfiles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250601200056_ReadingProfiles.cs b/API/Data/Migrations/20250601200056_ReadingProfiles.cs new file mode 100644 index 000000000..66b9e53e5 --- /dev/null +++ b/API/Data/Migrations/20250601200056_ReadingProfiles.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ReadingProfiles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserReadingProfiles", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false), + Kind = table.Column(type: "INTEGER", nullable: false), + LibraryIds = table.Column(type: "TEXT", nullable: true), + SeriesIds = table.Column(type: "TEXT", nullable: true), + ReadingDirection = table.Column(type: "INTEGER", nullable: false), + ScalingOption = table.Column(type: "INTEGER", nullable: false), + PageSplitOption = table.Column(type: "INTEGER", nullable: false), + ReaderMode = table.Column(type: "INTEGER", nullable: false), + AutoCloseMenu = table.Column(type: "INTEGER", nullable: false), + ShowScreenHints = table.Column(type: "INTEGER", nullable: false), + EmulateBook = table.Column(type: "INTEGER", nullable: false), + LayoutMode = table.Column(type: "INTEGER", nullable: false), + BackgroundColor = table.Column(type: "TEXT", nullable: true, defaultValue: "#000000"), + SwipeToPaginate = table.Column(type: "INTEGER", nullable: false), + AllowAutomaticWebtoonReaderDetection = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + WidthOverride = table.Column(type: "INTEGER", nullable: true), + BookReaderMargin = table.Column(type: "INTEGER", nullable: false), + BookReaderLineSpacing = table.Column(type: "INTEGER", nullable: false), + BookReaderFontSize = table.Column(type: "INTEGER", nullable: false), + BookReaderFontFamily = table.Column(type: "TEXT", nullable: true), + BookReaderTapToPaginate = table.Column(type: "INTEGER", nullable: false), + BookReaderReadingDirection = table.Column(type: "INTEGER", nullable: false), + BookReaderWritingStyle = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + BookThemeName = table.Column(type: "TEXT", nullable: true, defaultValue: "Dark"), + BookReaderLayoutMode = table.Column(type: "INTEGER", nullable: false), + BookReaderImmersiveMode = table.Column(type: "INTEGER", nullable: false), + PdfTheme = table.Column(type: "INTEGER", nullable: false), + PdfScrollMode = table.Column(type: "INTEGER", nullable: false), + PdfSpreadMode = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserReadingProfiles", x => x.Id); + table.ForeignKey( + name: "FK_AppUserReadingProfiles_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserReadingProfiles_AppUserId", + table: "AppUserReadingProfiles", + column: "AppUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserReadingProfiles"); + } + } +} diff --git a/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs new file mode 100644 index 000000000..0e9f00b4e --- /dev/null +++ b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.Designer.cs @@ -0,0 +1,3701 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint")] + partial class AppUserReadingProfileDisableWidthOverrideBreakPoint + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs new file mode 100644 index 000000000..11a554bdf --- /dev/null +++ b/API/Data/Migrations/20250610210618_AppUserReadingProfileDisableWidthOverrideBreakPoint.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AppUserReadingProfileDisableWidthOverrideBreakPoint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisableWidthOverride", + table: "AppUserReadingProfiles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DisableWidthOverride", + table: "AppUserReadingProfiles"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index ea758a00e..c9fb953df 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -609,6 +609,123 @@ namespace API.Data.Migrations b.ToTable("AppUserRating"); }); + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + modelBuilder.Entity("API.Entities.AppUserRole", b => { b.Property("UserId") @@ -2841,6 +2958,17 @@ namespace API.Data.Migrations b.Navigation("Series"); }); + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.AppUserRole", b => { b.HasOne("API.Entities.AppRole", "Role") @@ -3479,6 +3607,8 @@ namespace API.Data.Migrations b.Navigation("ReadingLists"); + b.Navigation("ReadingProfiles"); + b.Navigation("ScrobbleHolds"); b.Navigation("SideNavStreams"); diff --git a/API/Data/Repositories/AppUserReadingProfileRepository.cs b/API/Data/Repositories/AppUserReadingProfileRepository.cs new file mode 100644 index 000000000..11b97f21a --- /dev/null +++ b/API/Data/Repositories/AppUserReadingProfileRepository.cs @@ -0,0 +1,112 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Extensions.QueryExtensions; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + + +public interface IAppUserReadingProfileRepository +{ + + /// + /// Return the given profile if it belongs the user + /// + /// + /// + /// + Task GetUserProfile(int userId, int profileId); + /// + /// Returns all reading profiles for the user + /// + /// + /// + Task> GetProfilesForUser(int userId, bool skipImplicit = false); + /// + /// Returns all reading profiles for the user + /// + /// + /// + Task> GetProfilesDtoForUser(int userId, bool skipImplicit = false); + /// + /// Is there a user reading profile with this name (normalized) + /// + /// + /// + /// + Task IsProfileNameInUse(int userId, string name); + + void Add(AppUserReadingProfile readingProfile); + void Update(AppUserReadingProfile readingProfile); + void Remove(AppUserReadingProfile readingProfile); + void RemoveRange(IEnumerable readingProfiles); +} + +public class AppUserReadingProfileRepository(DataContext context, IMapper mapper): IAppUserReadingProfileRepository +{ + public async Task GetUserProfile(int userId, int profileId) + { + return await context.AppUserReadingProfiles + .Where(rp => rp.AppUserId == userId && rp.Id == profileId) + .FirstOrDefaultAsync(); + } + + public async Task> GetProfilesForUser(int userId, bool skipImplicit = false) + { + return await context.AppUserReadingProfiles + .Where(rp => rp.AppUserId == userId) + .WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit) + .ToListAsync(); + } + + /// + /// Returns all Reading Profiles for the User + /// + /// + /// + public async Task> GetProfilesDtoForUser(int userId, bool skipImplicit = false) + { + return await context.AppUserReadingProfiles + .Where(rp => rp.AppUserId == userId) + .WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit) + .ProjectTo(mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task IsProfileNameInUse(int userId, string name) + { + var normalizedName = name.ToNormalized(); + + return await context.AppUserReadingProfiles + .Where(rp => rp.NormalizedName == normalizedName && rp.AppUserId == userId) + .AnyAsync(); + } + + public void Add(AppUserReadingProfile readingProfile) + { + context.AppUserReadingProfiles.Add(readingProfile); + } + + public void Update(AppUserReadingProfile readingProfile) + { + context.AppUserReadingProfiles.Update(readingProfile).State = EntityState.Modified; + } + + public void Remove(AppUserReadingProfile readingProfile) + { + context.AppUserReadingProfiles.Remove(readingProfile); + } + + public void RemoveRange(IEnumerable readingProfiles) + { + context.AppUserReadingProfiles.RemoveRange(readingProfiles); + } +} diff --git a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs index 45882b5c4..377344a3c 100644 --- a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs +++ b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs @@ -108,14 +108,17 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor public async Task NeedsDataRefresh(int seriesId) { + // TODO: Add unit test var row = await _context.ExternalSeriesMetadata .Where(s => s.SeriesId == seriesId) .FirstOrDefaultAsync(); + return row == null || row.ValidUntilUtc <= DateTime.UtcNow; } public async Task GetSeriesDetailPlusDto(int seriesId) { + // TODO: Add unit test var seriesDetailDto = await _context.ExternalSeriesMetadata .Where(m => m.SeriesId == seriesId) .Include(m => m.ExternalRatings) @@ -144,7 +147,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - IEnumerable reviews = new List(); + IEnumerable reviews = []; if (seriesDetailDto.ExternalReviews != null && seriesDetailDto.ExternalReviews.Any()) { reviews = seriesDetailDto.ExternalReviews @@ -231,6 +234,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .Include(s => s.ExternalSeriesMetadata) .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) .Where(s => s.Library.AllowMetadataMatching) + .WhereIf(filter.LibraryType >= 0, s => s.Library.Type == (LibraryType) filter.LibraryType) .FilterMatchState(filter.MatchStateOption) .OrderBy(s => s.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index ef9dfa7ec..3e645cb2e 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -3,9 +3,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -27,6 +29,7 @@ public interface IGenreRepository Task GetRandomGenre(); Task GetGenreById(int id); Task> GetAllGenresNotInListAsync(ICollection genreNames); + Task> GetBrowseableGenre(int userId, UserParams userParams); } public class GenreRepository : IGenreRepository @@ -111,7 +114,7 @@ public class GenreRepository : IGenreRepository /// /// Returns a set of Genre tags for a set of library Ids. - /// UserId will restrict returned Genres based on user's age restriction and library access. + /// AppUserId will restrict returned Genres based on user's age restriction and library access. /// /// /// @@ -165,4 +168,28 @@ public class GenreRepository : IGenreRepository // Return the original non-normalized genres for the missing ones return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); } + + public async Task> GetBrowseableGenre(int userId, UserParams userParams) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var query = _context.Genre + .RestrictAgainstAgeRestriction(ageRating) + .Select(g => new BrowseGenreDto + { + Id = g.Id, + Title = g.Title, + SeriesCount = g.SeriesMetadatas + .Select(sm => sm.Id) + .Distinct() + .Count(), + ChapterCount = g.Chapters + .Select(ch => ch.Id) + .Distinct() + .Count() + }) + .OrderBy(g => g.Title); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index dce3f86ef..6954ccf03 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -2,13 +2,19 @@ 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; +using API.DTOs.Metadata.Browse.Requests; using API.DTOs.Person; using API.Entities.Enums; using API.Entities.Person; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Extensions.QueryExtensions.Filtering; using API.Helpers; +using API.Helpers.Converters; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -45,7 +51,7 @@ public interface IPersonRepository Task GetCoverImageAsync(int personId); Task GetCoverImageByNameAsync(string name); Task> GetRolesForPersonByName(int personId, int userId); - Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams); + Task> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams); Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None); Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases); /// @@ -194,36 +200,82 @@ public class PersonRepository : IPersonRepository return chapterRoles.Union(seriesRoles).Distinct(); } - public async Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams) + public async Task> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams) { - List roles = [PersonRole.Writer, PersonRole.CoverArtist]; var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); - var query = _context.Person - .Where(p => p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))) - .RestrictAgainstAgeRestriction(ageRating) - .Select(p => new BrowsePersonDto - { - Id = p.Id, - Name = p.Name, - Description = p.Description, - CoverImage = p.CoverImage, - SeriesCount = p.SeriesMetadataPeople - .Where(smp => roles.Contains(smp.Role)) - .Select(smp => smp.SeriesMetadata.SeriesId) - .Distinct() - .Count(), - IssueCount = p.ChapterPeople - .Where(cp => roles.Contains(cp.Role)) - .Select(cp => cp.Chapter.Id) - .Distinct() - .Count() - }) - .OrderBy(p => p.Name); + var query = CreateFilteredPersonQueryable(userId, filter, ageRating); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } + private IQueryable CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating) + { + var query = _context.Person.AsNoTracking(); + + // Apply filtering based on statements + query = BuildPersonFilterQuery(userId, filter, query); + + // Apply age restriction + query = query.RestrictAgainstAgeRestriction(ageRating); + + // Apply sorting and limiting + var sortedQuery = query.SortBy(filter.SortOptions); + + var limitedQuery = ApplyPersonLimit(sortedQuery, filter.LimitTo); + + // Project to DTO + var projectedQuery = limitedQuery.Select(p => new BrowsePersonDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + CoverImage = p.CoverImage, + SeriesCount = p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count(), + ChapterCount = p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() + }); + + return projectedQuery; + } + + private static IQueryable BuildPersonFilterQuery(int userId, BrowsePersonFilterDto filterDto, IQueryable query) + { + if (filterDto.Statements == null || filterDto.Statements.Count == 0) return query; + + var queries = filterDto.Statements + .Select(statement => BuildPersonFilterGroup(userId, statement, query)) + .ToList(); + + return filterDto.Combination == FilterCombination.And + ? queries.Aggregate((q1, q2) => q1.Intersect(q2)) + : queries.Aggregate((q1, q2) => q1.Union(q2)); + } + + private static IQueryable BuildPersonFilterGroup(int userId, PersonFilterStatementDto statement, IQueryable query) + { + var value = PersonFilterFieldValueConverter.ConvertValue(statement.Field, statement.Value); + + return statement.Field switch + { + PersonFilterField.Name => query.HasPersonName(true, statement.Comparison, (string)value), + PersonFilterField.Role => query.HasPersonRole(true, statement.Comparison, (IList)value), + PersonFilterField.SeriesCount => query.HasPersonSeriesCount(true, statement.Comparison, (int)value), + PersonFilterField.ChapterCount => query.HasPersonChapterCount(true, statement.Comparison, (int)value), + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") + }; + } + + private static IQueryable ApplyPersonLimit(IQueryable query, int limit) + { + return limit <= 0 ? query : query.Take(limit); + } + public async Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None) { return await _context.Person.Where(p => p.Id == personId) diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index e04c944e3..e2eab0976 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -735,6 +735,7 @@ public class SeriesRepository : ISeriesRepository { return await _context.Series .Where(s => s.Id == seriesId) + .Include(s => s.ExternalSeriesMetadata) .Select(series => new PlusSeriesRequestDto() { MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), @@ -744,6 +745,7 @@ public class SeriesRepository : ISeriesRepository ScrobblingService.AniListWeblinkWebsite), MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.MalWeblinkWebsite), + CbrId = series.ExternalSeriesMetadata.CbrId, GoogleBooksId = ScrobblingService.ExtractId(series.Metadata.WebLinks, ScrobblingService.GoogleBooksWeblinkWebsite), MangaDexId = ScrobblingService.ExtractId(series.Metadata.WebLinks, @@ -1088,8 +1090,6 @@ public class SeriesRepository : ISeriesRepository return query.Where(s => false); } - - // First setup any FilterField.Libraries in the statements, as these don't have any traditional query statements applied here query = ApplyLibraryFilter(filter, query); @@ -1290,7 +1290,7 @@ public class SeriesRepository : ISeriesRepository FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId), FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId), FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value), - _ => throw new ArgumentOutOfRangeException() + _ => throw new ArgumentOutOfRangeException(nameof(statement.Field), $"Unexpected value for field: {statement.Field}") }; } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index c4f189957..ea39d2b0d 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -2,9 +2,11 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs.Metadata; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -23,6 +25,7 @@ public interface ITagRepository Task RemoveAllTagNoLongerAssociated(); Task> GetAllTagDtosForLibrariesAsync(int userId, IList? libraryIds = null); Task> GetAllTagsNotInListAsync(ICollection tags); + Task> GetBrowseableTag(int userId, UserParams userParams); } public class TagRepository : ITagRepository @@ -104,6 +107,30 @@ public class TagRepository : ITagRepository return missingTags.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); } + public async Task> GetBrowseableTag(int userId, UserParams userParams) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var query = _context.Tag + .RestrictAgainstAgeRestriction(ageRating) + .Select(g => new BrowseTagDto + { + Id = g.Id, + Title = g.Title, + SeriesCount = g.SeriesMetadatas + .Select(sm => sm.Id) + .Distinct() + .Count(), + ChapterCount = g.Chapters + .Select(ch => ch.Id) + .Distinct() + .Count() + }) + .OrderBy(g => g.Title); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + public async Task> GetAllTagsAsync() { return await _context.Tag.ToListAsync(); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index e55338c8b..6437cfcfe 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -757,7 +757,7 @@ public class UserRepository : IUserRepository /// - /// Fetches the UserId by API Key. This does not include any extra information + /// Fetches the AppUserId by API Key. This does not include any extra information /// /// /// diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 74bfbb296..c08f80afa 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -120,7 +120,7 @@ public static class Seed new AppUserSideNavStream() { Name = "browse-authors", - StreamType = SideNavStreamType.BrowseAuthors, + StreamType = SideNavStreamType.BrowsePeople, Order = 6, IsProvided = true, Visible = true diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index c4a07dee7..d72dd3bc7 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -33,6 +33,7 @@ public interface IUnitOfWork IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } IEmailHistoryRepository EmailHistoryRepository { get; } + IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -74,6 +75,7 @@ public class UnitOfWork : IUnitOfWork AppUserExternalSourceRepository = new AppUserExternalSourceRepository(_context, _mapper); ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper); EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper); + AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper); } /// @@ -103,6 +105,7 @@ public class UnitOfWork : IUnitOfWork public IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } public IEmailHistoryRepository EmailHistoryRepository { get; } + public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; } /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 50f795041..848636209 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -21,6 +21,7 @@ public class AppUser : IdentityUser, IHasConcurrencyToken public ICollection Ratings { get; set; } = null!; public ICollection ChapterRatings { get; set; } = null!; public AppUserPreferences UserPreferences { get; set; } = null!; + public ICollection ReadingProfiles { get; set; } = null!; /// /// Bookmarks associated with this User /// diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index b728e84e5..b0f21bcba 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -1,4 +1,5 @@ -using API.Data; +using System.Collections.Generic; +using API.Data; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; diff --git a/API/Entities/AppUserReadingProfile.cs b/API/Entities/AppUserReadingProfile.cs new file mode 100644 index 000000000..9b238b4f5 --- /dev/null +++ b/API/Entities/AppUserReadingProfile.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.ComponentModel; +using API.Entities.Enums; +using API.Entities.Enums.UserPreferences; + +namespace API.Entities; + +public enum BreakPoint +{ + [Description("Never")] + Never = 0, + [Description("Mobile")] + Mobile = 1, + [Description("Tablet")] + Tablet = 2, + [Description("Desktop")] + Desktop = 3, +} + +public class AppUserReadingProfile +{ + public int Id { get; set; } + + public string Name { get; set; } + public string NormalizedName { get; set; } + + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } + + public ReadingProfileKind Kind { get; set; } + public List LibraryIds { get; set; } + public List SeriesIds { get; set; } + + #region MangaReader + + /// + /// Manga Reader Option: What direction should the next/prev page buttons go + /// + public ReadingDirection ReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// Manga Reader Option: How should the image be scaled to screen + /// + public ScalingOption ScalingOption { get; set; } = ScalingOption.Automatic; + /// + /// Manga Reader Option: Which side of a split image should we show first + /// + public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.FitSplit; + /// + /// Manga Reader Option: How the manga reader should perform paging or reading of the file + /// + /// Webtoon uses scrolling to page, MANGA_LR uses paging by clicking left/right side of reader, MANGA_UD uses paging + /// by clicking top/bottom sides of reader. + /// + /// + public ReaderMode ReaderMode { get; set; } + /// + /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction + /// + public bool AutoCloseMenu { get; set; } = true; + /// + /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change + /// + public bool ShowScreenHints { get; set; } = true; + /// + /// Manga Reader Option: Emulate a book by applying a shadow effect on the pages + /// + public bool EmulateBook { get; set; } = false; + /// + /// Manga Reader Option: How many pages to display in the reader at once + /// + public LayoutMode LayoutMode { get; set; } = LayoutMode.Single; + /// + /// Manga Reader Option: Background color of the reader + /// + public string BackgroundColor { get; set; } = "#000000"; + /// + /// Manga Reader Option: Should swiping trigger pagination + /// + public bool SwipeToPaginate { get; set; } + /// + /// Manga Reader Option: Allow Automatic Webtoon detection + /// + public bool AllowAutomaticWebtoonReaderDetection { get; set; } + /// + /// Manga Reader Option: Optional fixed width override + /// + public int? WidthOverride { get; set; } = null; + /// + /// Manga Reader Option: Disable the width override if the screen is past the breakpoint + /// + public BreakPoint DisableWidthOverride { get; set; } = BreakPoint.Never; + + #endregion + + #region EpubReader + + /// + /// Book Reader Option: Override extra Margin + /// + public int BookReaderMargin { get; set; } = 15; + /// + /// Book Reader Option: Override line-height + /// + public int BookReaderLineSpacing { get; set; } = 100; + /// + /// Book Reader Option: Override font size + /// + public int BookReaderFontSize { get; set; } = 100; + /// + /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override + /// + public string BookReaderFontFamily { get; set; } = "default"; + /// + /// Book Reader Option: Allows tapping on side of screens to paginate + /// + public bool BookReaderTapToPaginate { get; set; } = false; + /// + /// Book Reader Option: What direction should the next/prev page buttons go + /// + public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// Book Reader Option: Defines the writing styles vertical/horizontal + /// + public WritingStyle BookReaderWritingStyle { get; set; } = WritingStyle.Horizontal; + /// + /// Book Reader Option: The color theme to decorate the book contents + /// + /// Should default to Dark + public string BookThemeName { get; set; } = "Dark"; + /// + /// Book Reader Option: The way a page from a book is rendered. Default is as book dictates, 1 column is fit to height, + /// 2 column is fit to height, 2 columns + /// + /// Defaults to Default + public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default; + /// + /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. + /// + /// Defaults to false + public bool BookReaderImmersiveMode { get; set; } = false; + #endregion + + #region PdfReader + + /// + /// PDF Reader: Theme of the Reader + /// + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + /// + /// PDF Reader: Scroll mode of the reader + /// + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + /// + /// PDF Reader: Spread Mode of the reader + /// + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; + + + #endregion +} diff --git a/API/Entities/Enums/ReadingProfileKind.cs b/API/Entities/Enums/ReadingProfileKind.cs new file mode 100644 index 000000000..0f9cfa20b --- /dev/null +++ b/API/Entities/Enums/ReadingProfileKind.cs @@ -0,0 +1,17 @@ +namespace API.Entities.Enums; + +public enum ReadingProfileKind +{ + /// + /// Generate by Kavita when registering a user, this is your default profile + /// + Default, + /// + /// Created by the user in the UI or via the API + /// + User, + /// + /// Automatically generated by Kavita to track changes made in the readers. Can be converted to a User Reading Profile. + /// + Implicit +} diff --git a/API/Entities/SideNavStreamType.cs b/API/Entities/SideNavStreamType.cs index 545c630d8..62f429889 100644 --- a/API/Entities/SideNavStreamType.cs +++ b/API/Entities/SideNavStreamType.cs @@ -10,5 +10,5 @@ public enum SideNavStreamType ExternalSource = 6, AllSeries = 7, WantToRead = 8, - BrowseAuthors = 9 + BrowsePeople = 9 } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index cbfa9de1a..0d1d7d561 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -54,6 +54,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 4e84e2fa5..8beec88ca 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.RegularExpressions; using API.Data.Misc; using API.Entities.Enums; +using API.Entities.Metadata; namespace API.Extensions; #nullable enable @@ -42,4 +43,16 @@ public static class EnumerableExtensions return q; } + + public static IEnumerable RestrictAgainstAgeRestriction(this IEnumerable items, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return items; + var q = items.Where(s => s.AgeRating <= restriction.AgeRating); + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.AgeRating != AgeRating.Unknown); + } + + return q; + } } diff --git a/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs new file mode 100644 index 000000000..c36164d9d --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/PersonFilter.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; +using API.Entities.Person; +using Kavita.Common; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions.Filtering; + +public static class PersonFilter +{ + public static IQueryable HasPersonName(this IQueryable queryable, bool condition, + FilterComparison comparison, string queryString) + { + if (string.IsNullOrEmpty(queryString) || !condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.Name.Equals(queryString)), + FilterComparison.BeginsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"{queryString}%")), + FilterComparison.EndsWith => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}")), + FilterComparison.Matches => queryable.Where(p => EF.Functions.Like(p.Name, $"%{queryString}%")), + FilterComparison.NotEqual => queryable.Where(p => p.Name != queryString), + FilterComparison.NotContains or FilterComparison.GreaterThan or FilterComparison.GreaterThanEqual + or FilterComparison.LessThan or FilterComparison.LessThanEqual or FilterComparison.Contains + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast or FilterComparison.MustContains + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Name"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + public static IQueryable HasPersonRole(this IQueryable queryable, bool condition, + FilterComparison comparison, IList roles) + { + if (roles == null || roles.Count == 0 || !condition) return queryable; + + return comparison switch + { + FilterComparison.Contains or FilterComparison.MustContains => queryable.Where(p => + p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || + p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.NotContains => queryable.Where(p => + !p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) && + !p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))), + FilterComparison.Equal or FilterComparison.NotEqual or FilterComparison.BeginsWith + or FilterComparison.EndsWith or FilterComparison.Matches or FilterComparison.GreaterThan + or FilterComparison.GreaterThanEqual or FilterComparison.LessThan or FilterComparison.LessThanEqual + or FilterComparison.IsBefore or FilterComparison.IsAfter or FilterComparison.IsInLast + or FilterComparison.IsNotInLast + or FilterComparison.IsEmpty => + throw new KavitaException($"{comparison} not applicable for Person.Role"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, + "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonSeriesCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => p.SeriesMetadataPeople + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.SeriesCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } + + public static IQueryable HasPersonChapterCount(this IQueryable queryable, bool condition, + FilterComparison comparison, int count) + { + if (!condition) return queryable; + + return comparison switch + { + FilterComparison.Equal => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() == count), + FilterComparison.GreaterThan => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() > count), + FilterComparison.GreaterThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() >= count), + FilterComparison.LessThan => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() < count), + FilterComparison.LessThanEqual => queryable.Where(p => p.ChapterPeople + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() <= count), + FilterComparison.NotEqual => queryable.Where(p => + p.ChapterPeople.Select(cp => cp.Chapter.Id).Distinct().Count() != count), + FilterComparison.BeginsWith or FilterComparison.EndsWith or FilterComparison.Matches + or FilterComparison.Contains or FilterComparison.NotContains or FilterComparison.IsBefore + or FilterComparison.IsAfter or FilterComparison.IsInLast or FilterComparison.IsNotInLast + or FilterComparison.MustContains + or FilterComparison.IsEmpty => throw new KavitaException( + $"{comparison} not applicable for Person.ChapterCount"), + _ => throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported") + }; + } +} diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index a2db1dde7..ef2af721f 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -5,10 +5,13 @@ using System.Linq.Expressions; using System.Threading.Tasks; using API.Data.Misc; using API.Data.Repositories; +using API.DTOs; using API.DTOs.Filtering; using API.DTOs.KavitaPlus.Manage; +using API.DTOs.Metadata.Browse; using API.Entities; using API.Entities.Enums; +using API.Entities.Person; using API.Entities.Scrobble; using Microsoft.EntityFrameworkCore; @@ -273,6 +276,27 @@ public static class QueryableExtensions }; } + public static IQueryable SortBy(this IQueryable query, PersonSortOptions? sort) + { + if (sort == null) + { + return query.OrderBy(p => p.Name); + } + + return sort.SortField switch + { + PersonSortField.Name when sort.IsAscending => query.OrderBy(p => p.Name), + PersonSortField.Name => query.OrderByDescending(p => p.Name), + PersonSortField.SeriesCount when sort.IsAscending => query.OrderBy(p => p.SeriesMetadataPeople.Count), + PersonSortField.SeriesCount => query.OrderByDescending(p => p.SeriesMetadataPeople.Count), + PersonSortField.ChapterCount when sort.IsAscending => query.OrderBy(p => p.ChapterPeople.Count), + PersonSortField.ChapterCount => query.OrderByDescending(p => p.ChapterPeople.Count), + _ => query.OrderBy(p => p.Name) + }; + + + } + /// /// Performs either OrderBy or OrderByDescending on the given query based on the value of SortOptions.IsAscending. /// diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index fc3314f58..aef595596 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -3,6 +3,7 @@ using System.Linq; using API.Data.Misc; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Entities.Person; namespace API.Extensions.QueryExtensions; @@ -26,6 +27,7 @@ public static class RestrictByAgeExtensions return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -39,20 +41,6 @@ public static class RestrictByAgeExtensions return q; } - [Obsolete] - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) - { - if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; - - if (restriction.IncludeUnknowns) - { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); - } - - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); - } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { @@ -74,12 +62,15 @@ public static class RestrictByAgeExtensions if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) @@ -88,12 +79,15 @@ public static class RestrictByAgeExtensions if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadatas.Any(sm => sm.AgeRating <= restriction.AgeRating && sm.AgeRating != AgeRating.Unknown) || + c.Chapters.Any(cp => cp.AgeRating <= restriction.AgeRating && cp.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 75183fdcd..bb7511c64 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -275,19 +275,19 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.Theme, opt => - opt.MapFrom(src => src.Theme)) + opt.MapFrom(src => src.Theme)); + + CreateMap() .ForMember(dest => dest.BookReaderThemeName, opt => - opt.MapFrom(src => src.BookThemeName)) - .ForMember(dest => dest.BookReaderLayoutMode, - opt => - opt.MapFrom(src => src.BookReaderLayoutMode)); + opt.MapFrom(src => src.BookThemeName)); CreateMap(); CreateMap() - .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)) + .ForMember(dest => dest.OwnerUserName, opt => opt.MapFrom(src => src.AppUser.UserName)); CreateMap(); CreateMap(); CreateMap(); diff --git a/API/Helpers/Builders/AppUserBuilder.cs b/API/Helpers/Builders/AppUserBuilder.cs index 282361e41..7ffac355e 100644 --- a/API/Helpers/Builders/AppUserBuilder.cs +++ b/API/Helpers/Builders/AppUserBuilder.cs @@ -21,7 +21,7 @@ public class AppUserBuilder : IEntityBuilder ApiKey = HashUtil.ApiKey(), UserPreferences = new AppUserPreferences { - Theme = theme ?? Seed.DefaultThemes.First() + Theme = theme ?? Seed.DefaultThemes.First(), }, ReadingLists = new List(), Bookmarks = new List(), @@ -31,7 +31,8 @@ public class AppUserBuilder : IEntityBuilder Devices = new List(), Id = 0, DashboardStreams = new List(), - SideNavStreams = new List() + SideNavStreams = new List(), + ReadingProfiles = [], }; } diff --git a/API/Helpers/Builders/AppUserReadingProfileBuilder.cs b/API/Helpers/Builders/AppUserReadingProfileBuilder.cs new file mode 100644 index 000000000..26da5fd86 --- /dev/null +++ b/API/Helpers/Builders/AppUserReadingProfileBuilder.cs @@ -0,0 +1,54 @@ +using API.Entities; +using API.Entities.Enums; +using API.Extensions; + +namespace API.Helpers.Builders; + +public class AppUserReadingProfileBuilder +{ + private readonly AppUserReadingProfile _profile; + + public AppUserReadingProfile Build() => _profile; + + /// + /// The profile's kind will be unless overwritten with + /// + /// + public AppUserReadingProfileBuilder(int userId) + { + _profile = new AppUserReadingProfile + { + AppUserId = userId, + Kind = ReadingProfileKind.User, + SeriesIds = [], + LibraryIds = [] + }; + } + + public AppUserReadingProfileBuilder WithSeries(Series series) + { + _profile.SeriesIds.Add(series.Id); + return this; + } + + public AppUserReadingProfileBuilder WithLibrary(Library library) + { + _profile.LibraryIds.Add(library.Id); + return this; + } + + public AppUserReadingProfileBuilder WithKind(ReadingProfileKind kind) + { + _profile.Kind = kind; + return this; + } + + public AppUserReadingProfileBuilder WithName(string name) + { + _profile.Name = name; + _profile.NormalizedName = name.ToNormalized(); + return this; + } + + +} diff --git a/API/Helpers/Converters/PersonFilterFieldValueConverter.cs b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs new file mode 100644 index 000000000..822ce105a --- /dev/null +++ b/API/Helpers/Converters/PersonFilterFieldValueConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.DTOs.Filtering.v2; +using API.Entities.Enums; + +namespace API.Helpers.Converters; + +public static class PersonFilterFieldValueConverter +{ + public static object ConvertValue(PersonFilterField field, string value) + { + return field switch + { + PersonFilterField.Name => value, + PersonFilterField.Role => ParsePersonRoles(value), + PersonFilterField.SeriesCount => int.Parse(value), + PersonFilterField.ChapterCount => int.Parse(value), + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not supported") + }; + } + + private static IList ParsePersonRoles(string value) + { + if (string.IsNullOrEmpty(value)) return []; + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(v => Enum.Parse(v.Trim())) + .ToList(); + } +} diff --git a/API/I18N/as.json b/API/I18N/as.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/API/I18N/as.json @@ -0,0 +1 @@ +{} diff --git a/API/I18N/cs.json b/API/I18N/cs.json index 9825ab074..e136d8e75 100644 --- a/API/I18N/cs.json +++ b/API/I18N/cs.json @@ -208,5 +208,6 @@ "smart-filter-name-required": "Vyžaduje se název chytrého filtru", "smart-filter-system-name": "Nelze použít název streamu poskytovaného systémem", "sidenav-stream-only-delete-smart-filter": "Z postranní navigace lze odstranit pouze streamy chytrých filtrů", - "aliases-have-overlap": "Jeden nebo více aliasů se překrývají s jinými osobami, nelze je aktualizovat" + "aliases-have-overlap": "Jeden nebo více aliasů se překrývají s jinými osobami, nelze je aktualizovat", + "generated-reading-profile-name": "Generováno z {0}" } diff --git a/API/I18N/en.json b/API/I18N/en.json index 5916bc63e..d3cd1ecd3 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -230,6 +230,8 @@ "scan-libraries": "Scan Libraries", "kavita+-data-refresh": "Kavita+ Data Refresh", "backup": "Backup", - "update-yearly-stats": "Update Yearly Stats" + "update-yearly-stats": "Update Yearly Stats", + + "generated-reading-profile-name": "Generated from {0}" } diff --git a/API/I18N/ga.json b/API/I18N/ga.json index 79d0d271e..142425aec 100644 --- a/API/I18N/ga.json +++ b/API/I18N/ga.json @@ -208,5 +208,6 @@ "sidenav-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh as an SideNav", "dashboard-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh ón deais", "smart-filter-name-required": "Ainm Scagaire Cliste ag teastáil", - "aliases-have-overlap": "Tá forluí idir ceann amháin nó níos mó de na leasainmneacha agus daoine eile, ní féidir iad a nuashonrú" + "aliases-have-overlap": "Tá forluí idir ceann amháin nó níos mó de na leasainmneacha agus daoine eile, ní féidir iad a nuashonrú", + "generated-reading-profile-name": "Gineadh ó {0}" } diff --git a/API/I18N/he.json b/API/I18N/he.json index 41a9a7de7..3b2386bf6 100644 --- a/API/I18N/he.json +++ b/API/I18N/he.json @@ -21,5 +21,6 @@ "age-restriction-update": "אירעה תקלה בעת עדכון הגבלת גיל", "generic-user-update": "אירעה תקלה בעת עדכון משתמש", "user-already-registered": "משתמש רשום כבר בתור {0}", - "manual-setup-fail": "לא מתאפשר להשלים הגדרה ידנית. יש לבטל וליצור מחדש את ההזמנה" + "manual-setup-fail": "לא מתאפשר להשלים הגדרה ידנית. יש לבטל וליצור מחדש את ההזמנה", + "email-taken": "דואר אלקטרוני כבר בשימוש" } diff --git a/API/I18N/pt_BR.json b/API/I18N/pt_BR.json index b67d926b0..418e0ea3b 100644 --- a/API/I18N/pt_BR.json +++ b/API/I18N/pt_BR.json @@ -208,5 +208,6 @@ "dashboard-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do painel", "smart-filter-system-name": "Você não pode usar o nome de um fluxo fornecido pelo sistema", "sidenav-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do Navegador Lateral", - "aliases-have-overlap": "Um ou mais dos pseudônimos se sobrepõem a outras pessoas, não pode atualizar" + "aliases-have-overlap": "Um ou mais dos pseudônimos se sobrepõem a outras pessoas, não pode atualizar", + "generated-reading-profile-name": "Gerado a partir de {0}" } diff --git a/API/I18N/ru.json b/API/I18N/ru.json index 92d842336..fdea5920f 100644 --- a/API/I18N/ru.json +++ b/API/I18N/ru.json @@ -1,5 +1,5 @@ { - "confirm-email": "Вы обязаны сначала подтвердить свою почту", + "confirm-email": "Сначала Вы обязаны подтвердить свою электронную почту", "generate-token": "Возникла проблема с генерацией токена подтверждения электронной почты. Смотрите журналы", "invalid-password": "Неверный пароль", "invalid-email-confirmation": "Неверное подтверждение электронной почты", @@ -35,15 +35,15 @@ "no-user": "Пользователь не существует", "generic-invite-user": "Возникла проблема с приглашением пользователя. Пожалуйста, проверьте журналы.", "permission-denied": "Вам запрещено выполнять эту операцию", - "invalid-access": "Недопустимый доступ", + "invalid-access": "В доступе отказано", "reading-list-name-exists": "Такой список для чтения уже существует", "perform-scan": "Пожалуйста, выполните сканирование этой серии или библиотеки и повторите попытку", "generic-device-create": "При создании устройства возникла ошибка", "generic-read-progress": "Возникла проблема с сохранением прогресса", "file-doesnt-exist": "Файл не существует", "admin-already-exists": "Администратор уже существует", - "send-to-kavita-email": "Отправка на устройство не может быть использована с почтовым сервисом Kavita. Пожалуйста, настройте свой собственный.", - "no-image-for-page": "Нет такого изображения для страницы {0}. Попробуйте обновить, чтобы обновить кэш.", + "send-to-kavita-email": "Отправка на устройство не может быть использована без настройки электронной почты", + "no-image-for-page": "Нет такого изображения для страницы {0}. Попробуйте обновить, для повторного кеширования.", "reading-list-permission": "У вас нет прав на этот список чтения или список не существует", "volume-doesnt-exist": "Том не существует", "generic-library": "Возникла критическая проблема. Пожалуйста, попробуйте еще раз.", @@ -57,7 +57,7 @@ "generic-reading-list-create": "Возникла проблема с созданием списка для чтения", "no-cover-image": "Изображение на обложке отсутствует", "collection-updated": "Коллекция успешно обновлена", - "critical-email-migration": "Возникла проблема при переносе электронной почты. Обратитесь в службу поддержки", + "critical-email-migration": "Возникла проблема при смене электронной почты. Обратитесь в службу поддержки", "cache-file-find": "Не удалось найти изображение в кэше. Перезагрузитесь и попробуйте снова.", "duplicate-bookmark": "Дублирующая закладка уже существует", "collection-tag-duplicate": "Такая коллекция уже существует", @@ -72,7 +72,7 @@ "pdf-doesnt-exist": "PDF не существует, когда он должен существовать", "generic-device-delete": "При удалении устройства возникла ошибка", "bookmarks-empty": "Закладки не могут быть пустыми", - "valid-number": "Должен быть действительный номер страницы", + "valid-number": "Номер страницы должен быть действительным", "series-doesnt-exist": "Серия не существует", "no-library-access": "Пользователь не имеет доступа к этой библиотеке", "reading-list-item-delete": "Не удалось удалить элемент(ы)", @@ -155,7 +155,7 @@ "generic-user-delete": "Не удалось удалить пользователя", "generic-cover-reading-list-save": "Невозможно сохранить изображение обложки в списке для чтения", "unable-to-register-k+": "Невозможно зарегистрировать лицензию из-за ошибки. Обратитесь в службу поддержки Кавита+", - "encode-as-warning": "Конвертировать в PNG невозможно. Для обложек используйте Обновить Обложки. Закладки и фавиконки нельзя закодировать обратно.", + "encode-as-warning": "Вы не можете конвертировать в формат PNG. Для обновления обложек используйте команду \"Обновить обложку\". Закладки и значки не могут быть закодированы обратно.", "want-to-read": "Хотите прочитать", "generic-user-pref": "Возникла проблема с сохранением предпочтений", "external-sources": "Внешние источники", @@ -197,7 +197,7 @@ "kavita+-data-refresh": "Обновление данных Kavita+", "kavitaplus-restricted": "Это доступно только для Kavita+", "person-doesnt-exist": "Персона не существует", - "generic-cover-volume-save": "Не удается сохранить обложку для раздела", + "generic-cover-volume-save": "Не удается сохранить обложку для тома", "generic-cover-person-save": "Не удается сохранить изображение обложки для Персоны", "person-name-unique": "Имя персоны должно быть уникальным", "person-image-doesnt-exist": "Персона не существует в CoversDB", diff --git a/API/I18N/sk.json b/API/I18N/sk.json index 0967ef424..a48add072 100644 --- a/API/I18N/sk.json +++ b/API/I18N/sk.json @@ -1 +1,92 @@ -{} +{ + "disabled-account": "Váš účet je zakázaný. Kontaktujte správcu servera.", + "register-user": "Niečo sa pokazilo pri registrácii užívateľa", + "confirm-email": "Najprv musíte potvrdiť svoj e-mail", + "locked-out": "Boli ste zamknutí z dôvodu veľkého počtu neúspešných pokusov o prihlásenie. Počkajte 10 minút.", + "validate-email": "Pri validácii vášho e-mailu sa vyskytla chyba: {0}", + "confirm-token-gen": "Pri vytváraní potvrdzovacieho tokenu sa vyskytla chyba", + "permission-denied": "Na vykonanie tejto úlohy nemáte oprávnenie", + "password-required": "Ak nie ste administrátor, musíte na vykonanie zmien vo vašom profile zadať vaše aktuálne heslo", + "invalid-password": "Nesprávne heslo", + "invalid-token": "Nesprávny token", + "unable-to-reset-key": "Niečo sa pokazilo, kľúč nie je možné resetovať", + "invalid-payload": "Nesprávny payload", + "nothing-to-do": "Nič na vykonanie", + "share-multiple-emails": "Nemôžete zdielať e-maily medzi rôznymi účtami", + "generate-token": "Pri generovaní potvrdzovacieho tokenu e-mailu sa vyskytla chyba. Pozrite záznamy udalostí", + "age-restriction-update": "Pri aktualizovaní vekového obmedzenia sa vyskytla chyba", + "no-user": "Používateľ neexistuje", + "generic-user-update": "Aktualizácia používateľa prebehla s výnimkou", + "username-taken": "Používateľské meno už existuje", + "user-already-confirmed": "Používateľ je už potvrdený", + "user-already-registered": "Používateľ je už registrovaný ako {0}", + "user-already-invited": "Používateľ je už pod týmto e-mailom pozvaný a musí ešte prijať pozvanie.", + "generic-password-update": "Pri potvrdení nového hesla sa vyskytla neočakávaná chyba", + "generic-invite-user": "Pri pozývaní tohto používateľa sa vyskytla chyba. Pozrite záznamy udalostí.", + "password-updated": "Heslo aktualizované", + "forgot-password-generic": "E-mail bude odoslaný na zadanú adresu len v prípade, ak existuje v databáze", + "invalid-email-confirmation": "Neplatné potvrdenie e-mailu", + "not-accessible-password": "Váš server nie je dostupný. Odkaz na resetovanie vášho hesla je v záznamoch udalostí", + "email-taken": "Zadaný e-mail už existuje", + "denied": "Nepovolené", + "manual-setup-fail": "Manuálne nastavenie nie je možné dokončiť. Prosím zrušte aktuálny postup a znovu vytvorte pozvánku", + "generic-user-email-update": "Nemožno aktualizovať e-mail používateľa. Skontrolujte záznamy udalostí.", + "email-not-enabled": "E-mail nie je na tomto serveri povolený. Preto túto akciu nemôžete vykonať.", + "collection-updated": "Zbierka bola úspešne aktualizovaná", + "device-doesnt-exist": "Zariadenie neexistuje", + "generic-device-delete": "Pri odstraňovaní zariadenia sa vyskytla chyba", + "greater-0": "{0} musí byť väčší ako 0", + "send-to-size-limit": "Snažíte sa odoslať súbor(y), ktoré sú príliš veľké pre vášho e-mailového poskytovateľa", + "send-to-device-status": "Prenos súborov do vášho zariadenia", + "no-cover-image": "Žiadny prebal", + "must-be-defined": "{0} musí byť definovaný", + "generic-favicon": "Pri získavaní favicon-u domény sa vyskytla chyba", + "no-library-access": "Pozužívateľ nemá prístup do tejto knižnice", + "user-doesnt-exist": "Používateľ neexistuje", + "collection-already-exists": "Zbierka už existuje", + "not-accessible": "Váš server nie je dostupný z vonkajšieho prostredia", + "email-sent": "E-mail odoslaný", + "user-migration-needed": "Uvedený používateľ potrebuje migrovať. Odhláste ho a opäť prihláste na spustenie migrácie", + "generic-invite-email": "Pri opakovanom odosielaní pozývacieho e-mailu sa vyskytla chyba", + "email-settings-invalid": "V nastaveniach e-mailu chýbajú potrebné údaje. Uistite sa, že všetky nastavenia e-mailu sú uložené.", + "chapter-doesnt-exist": "Kapitola neexistuje", + "critical-email-migration": "Počas migrácie e-mailu sa vyskytla chyba. Kontaktujte podporu", + "collection-deleted": "Zbierka bola vymazaná", + "generic-error": "Niečo sa pokazilo, skúste to znova", + "collection-doesnt-exist": "Zbierka neexistuje", + "generic-device-update": "Pri aktualizácii zariadenia sa vyskytla chyba", + "bookmark-doesnt-exist": "Záložka neexistuje", + "person-doesnt-exist": "Osoba neexistuje", + "send-to-kavita-email": "Odoslanie do zariadenia nemôže byť použité bez nastavenia e-amilu", + "send-to-unallowed": "Nemôžete odosielať do zariadenia, ktoré nie je vaše", + "generic-library": "Vyskytla sa kritická chyba. Prosím skúste to opäť.", + "pdf-doesnt-exist": "PDF neexistuje, hoci by malo", + "generic-library-update": "Počas aktualizácie knižnice sa vyskytla kritická chyba.", + "invalid-access": "Neplatný prístup", + "perform-scan": "Prosím, vykonajte opakovaný sken na tejto sérii alebo knižnici", + "generic-read-progress": "Pri ukladaní aktuálneho stavu sa vyskytla chyba", + "generic-clear-bookmarks": "Záložky nie je možné vymazať", + "bookmark-permission": "Nemáte oprávnenie na vkladanie/odstraňovanie záložiek", + "bookmark-save": "Nemožno uložiť záložku", + "bookmarks-empty": "Záložky nemôžu byť prázdne", + "library-doesnt-exist": "Knižnica neexistuje", + "invalid-path": "Neplatné umiestnenie", + "generic-send-to": "Pri odosielaní súboru(-ov) do vášho zariadenia sa vyskytla chyba", + "no-image-for-page": "Žiadny taký obrázok pre stránku {0}. Pokúste sa ju obnoviť, aby ste ju mohli nanovo uložiť.", + "delete-library-while-scan": "Nemôžete odstrániť knižnicu počas prebiehajúceho skenovania. Prosím, vyčkajte na dokončenie skenovania alebo reštartujte Kavitu a skúste ju opäť odstrániť", + "invalid-username": "Neplatné používateľské meno", + "account-email-invalid": "E-mail uvedený v údajoch administrátora nie je platným e-mailom. Nie je možné zaslať testovací e-mail.", + "admin-already-exists": "Administrátor už existuje", + "invalid-filename": "Neplatný názov súboru", + "file-doesnt-exist": "Súbor neexistuje", + "invalid-email": "E-mail v záznamoch pre používateľov nie platný e-mail. Odkazy sú uvedené v záznamoch udalostí.", + "file-missing": "Súbor nebol nájdený v knihe", + "error-import-stack": "Pri importovaní MAL balíka sa vyskytla chyba", + "person-name-required": "Meno osoby je povinné a nesmie byť prázdne", + "person-name-unique": "Meno osoby musí byť jedinečné", + "person-image-doesnt-exist": "Osoba neexistuje v databáze CoversDB", + "generic-device-create": "Pri vytváraní zariadenia sa vyskytla chyba", + "series-doesnt-exist": "Séria neexistuje", + "volume-doesnt-exist": "Zväzok neexistuje", + "library-name-exists": "Názov knižnice už existuje. Prosím, vyberte si pre daný server jedinečný názov." +} diff --git a/API/I18N/zh_Hans.json b/API/I18N/zh_Hans.json index 070a87855..14c8c902e 100644 --- a/API/I18N/zh_Hans.json +++ b/API/I18N/zh_Hans.json @@ -208,5 +208,6 @@ "smart-filter-name-required": "需要智能筛选器名称", "smart-filter-system-name": "您不能使用系统提供的流名称", "sidenav-stream-only-delete-smart-filter": "只能从侧边栏删除智能筛选器流", - "aliases-have-overlap": "一个或多个别名与其他人有重叠,无法更新" + "aliases-have-overlap": "一个或多个别名与其他人有重叠,无法更新", + "generated-reading-profile-name": "由 {0} 生成" } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 0255b785d..544efa4ce 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -10,11 +10,9 @@ using API.Entities.Interfaces; using API.Extensions; using Microsoft.Extensions.Logging; using NetVips; -using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing.Processors.Quantization; -using Color = System.Drawing.Color; using Image = NetVips.Image; namespace API.Services; @@ -750,7 +748,7 @@ public class ImageService : IImageService } - public static Color HexToRgb(string? hex) + public static (int R, int G, int B) HexToRgb(string? hex) { if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null"); @@ -774,7 +772,7 @@ public class ImageService : IImageService var g = Convert.ToInt32(hex.Substring(2, 2), 16); var b = Convert.ToInt32(hex.Substring(4, 2), 16); - return Color.FromArgb(r, g, b); + return (r, g, b); } diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index a1e3750dd..435727bda 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -200,6 +200,9 @@ public class ExternalMetadataService : IExternalMetadataService /// /// Returns the match results for a Series from UI Flow /// + /// + /// Will extract alternative names like Localized name, year will send as ReleaseYear but fallback to Comic Vine syntax if applicable + /// /// /// public async Task> MatchSeries(MatchSeriesDto dto) @@ -212,19 +215,26 @@ public class ExternalMetadataService : IExternalMetadataService var potentialAnilistId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.AniListWeblinkWebsite); var potentialMalId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.MalWeblinkWebsite); - List altNames = [series.LocalizedName, series.OriginalName]; - if (potentialAnilistId == null && potentialMalId == null && !string.IsNullOrEmpty(dto.Query)) + var format = series.Library.Type.ConvertToPlusMediaFormat(series.Format); + var otherNames = ExtractAlternativeNames(series); + + var year = series.Metadata.ReleaseYear; + if (year == 0 && format == PlusMediaFormat.Comic && !string.IsNullOrWhiteSpace(series.Name)) { - altNames.Add(dto.Query); + var potentialYear = Parser.ParseYear(series.Name); + if (!string.IsNullOrEmpty(potentialYear)) + { + year = int.Parse(potentialYear); + } } var matchRequest = new MatchSeriesRequestDto() { - Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), + Format = format, Query = dto.Query, SeriesName = series.Name, - AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(), - Year = series.Metadata.ReleaseYear, + AlternativeNames = otherNames, + Year = year, AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), MalId = potentialMalId ?? ScrobblingService.GetMalId(series) }; @@ -254,6 +264,12 @@ public class ExternalMetadataService : IExternalMetadataService return ArraySegment.Empty; } + private static List ExtractAlternativeNames(Series series) + { + List altNames = [series.LocalizedName, series.OriginalName]; + return altNames.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); + } + /// /// Retrieves Metadata about a Recommended External Series diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index 774103518..91f5a8fdd 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -130,22 +130,23 @@ public class LicenseService( if (cacheValue.HasValue) return cacheValue.Value; } + var result = false; try { var serverSetting = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - var result = await IsLicenseValid(serverSetting.Value); - await provider.FlushAsync(); - await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); - return result; + result = await IsLicenseValid(serverSetting.Value); } catch (Exception ex) { logger.LogError(ex, "There was an issue connecting to Kavita+"); + } + finally + { await provider.FlushAsync(); - await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); + await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); } - return false; + return result; } /// diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index ef22736d2..85814dcd9 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -261,7 +261,7 @@ public class ScrobblingService : IScrobblingService var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); - _logger.LogInformation("Processing Scrobbling review event for {UserId} on {SeriesName}", userId, series.Name); + _logger.LogInformation("Processing Scrobbling review event for {AppUserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; if (IsAniListReviewValid(reviewTitle, reviewBody)) @@ -297,7 +297,7 @@ public class ScrobblingService : IScrobblingService }; _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Review update on {SeriesName} with Userid {UserId} ", series.Name, userId); + _logger.LogDebug("Added Scrobbling Review update on {SeriesName} with Userid {AppUserId} ", series.Name, userId); } private static bool IsAniListReviewValid(string reviewTitle, string reviewBody) @@ -317,7 +317,7 @@ public class ScrobblingService : IScrobblingService var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; - _logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name); + _logger.LogInformation("Processing Scrobbling rating event for {AppUserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, @@ -346,7 +346,7 @@ public class ScrobblingService : IScrobblingService }; _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId}", series.Name, userId); + _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {AppUserId}", series.Name, userId); } public static long? GetMalId(Series series) @@ -371,7 +371,7 @@ public class ScrobblingService : IScrobblingService var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; - _logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name); + _logger.LogInformation("Processing Scrobbling reading event for {AppUserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, @@ -418,7 +418,7 @@ public class ScrobblingService : IScrobblingService _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Read update on {SeriesName} - Volume: {VolumeNumber} Chapter: {ChapterNumber} for User: {UserId}", series.Name, evt.VolumeNumber, evt.ChapterNumber, userId); + _logger.LogDebug("Added Scrobbling Read update on {SeriesName} - Volume: {VolumeNumber} Chapter: {ChapterNumber} for User: {AppUserId}", series.Name, evt.VolumeNumber, evt.ChapterNumber, userId); } catch (Exception ex) { @@ -437,7 +437,7 @@ public class ScrobblingService : IScrobblingService if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; if (await CheckIfCannotScrobble(userId, seriesId, series)) return; - _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); + _logger.LogInformation("Processing Scrobbling want-to-read event for {AppUserId} on {SeriesName}", userId, series.Name); // Get existing events for this series/user var existingEvents = (await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId)) @@ -463,7 +463,7 @@ public class ScrobblingService : IScrobblingService _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId); + _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {AppUserId} ", series.Name, userId); } private async Task CheckIfCannotScrobble(int userId, int seriesId, Series series) @@ -471,7 +471,7 @@ public class ScrobblingService : IScrobblingService if (series.DontMatch) return true; if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) { - _logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, + _logger.LogInformation("Series {SeriesName} is on AppUserId {AppUserId}'s hold list. Not scrobbling", series.Name, userId); return true; } @@ -750,7 +750,7 @@ public class ScrobblingService : IScrobblingService /// public async Task ClearEventsForSeries(int userId, int seriesId) { - _logger.LogInformation("Clearing Pre-existing Scrobble events for Series {SeriesId} by User {UserId} as Series is now on hold list", seriesId, userId); + _logger.LogInformation("Clearing Pre-existing Scrobble events for Series {SeriesId} by User {AppUserId} as Series is now on hold list", seriesId, userId); var events = await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId); foreach (var scrobble in events) { @@ -1109,7 +1109,7 @@ public class ScrobblingService : IScrobblingService { if (ex.Message.Contains("Access token is invalid")) { - _logger.LogCritical(ex, "Access Token for UserId: {UserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id); + _logger.LogCritical(ex, "Access Token for AppUserId: {AppUserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id); evt.IsErrored = true; evt.ErrorDetails = AccessTokenErrorMessage; _unitOfWork.ScrobbleRepository.Update(evt); diff --git a/API/Services/ReadingProfileService.cs b/API/Services/ReadingProfileService.cs new file mode 100644 index 000000000..4c3dab006 --- /dev/null +++ b/API/Services/ReadingProfileService.cs @@ -0,0 +1,454 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers.Builders; +using AutoMapper; +using Kavita.Common; + +namespace API.Services; +#nullable enable + +public interface IReadingProfileService +{ + /// + /// Returns the ReadingProfile that should be applied to the given series, walks up the tree. + /// Series (Implicit) -> Series (User) -> Library (User) -> Default + /// + /// + /// + /// + /// + Task GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false); + + /// + /// Creates a new reading profile for a user. Name must be unique per user + /// + /// + /// + /// + Task CreateReadingProfile(int userId, UserReadingProfileDto dto); + Task PromoteImplicitProfile(int userId, int profileId); + + /// + /// Updates the implicit reading profile for a series, creates one if none exists + /// + /// + /// + /// + /// + Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto); + + /// + /// Updates the non-implicit reading profile for the given series, and removes implicit profiles + /// + /// + /// + /// + /// + Task UpdateParent(int userId, int seriesId, UserReadingProfileDto dto); + + /// + /// Updates a given reading profile for a user + /// + /// + /// + /// + /// Does not update connected series and libraries + Task UpdateReadingProfile(int userId, UserReadingProfileDto dto); + + /// + /// Deletes a given profile for a user + /// + /// + /// + /// + /// + /// The default profile for the user cannot be deleted + Task DeleteReadingProfile(int userId, int profileId); + + /// + /// Binds the reading profile to the series, and remove the implicit RP from the series if it exists + /// + /// + /// + /// + /// + Task AddProfileToSeries(int userId, int profileId, int seriesId); + /// + /// Binds the reading profile to many series, and remove the implicit RP from the series if it exists + /// + /// + /// + /// + /// + Task BulkAddProfileToSeries(int userId, int profileId, IList seriesIds); + /// + /// Remove all reading profiles bound to the series + /// + /// + /// + /// + Task ClearSeriesProfile(int userId, int seriesId); + + /// + /// Bind the reading profile to the library + /// + /// + /// + /// + /// + Task AddProfileToLibrary(int userId, int profileId, int libraryId); + /// + /// Remove the reading profile bound to the library, if it exists + /// + /// + /// + /// + Task ClearLibraryProfile(int userId, int libraryId); + /// + /// Returns the bound Reading Profile to a Library + /// + /// + /// + /// + Task GetReadingProfileDtoForLibrary(int userId, int libraryId); +} + +public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper): IReadingProfileService +{ + /// + /// Tries to resolve the Reading Profile for a given Series. Will first check (optionally) Implicit profiles, then check for a bound Series profile, then a bound + /// Library profile, then default to the default profile. + /// + /// + /// + /// + /// + /// + public async Task GetReadingProfileForSeries(int userId, int seriesId, bool skipImplicit = false) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, skipImplicit); + + // If there is an implicit, send back + var implicitProfile = + profiles.FirstOrDefault(p => p.SeriesIds.Contains(seriesId) && p.Kind == ReadingProfileKind.Implicit); + if (implicitProfile != null) return implicitProfile; + + // Next check for a bound Series profile + var seriesProfile = profiles + .FirstOrDefault(p => p.SeriesIds.Contains(seriesId) && p.Kind != ReadingProfileKind.Implicit); + if (seriesProfile != null) return seriesProfile; + + // Check for a library bound profile + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null) throw new KavitaException(await localizationService.Translate(userId, "series-doesnt-exist")); + + var libraryProfile = profiles + .FirstOrDefault(p => p.LibraryIds.Contains(series.LibraryId) && p.Kind != ReadingProfileKind.Implicit); + if (libraryProfile != null) return libraryProfile; + + // Fallback to the default profile + return profiles.First(p => p.Kind == ReadingProfileKind.Default); + } + + public async Task GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false) + { + return mapper.Map(await GetReadingProfileForSeries(userId, seriesId, skipImplicit)); + } + + public async Task UpdateParent(int userId, int seriesId, UserReadingProfileDto dto) + { + var parentProfile = await GetReadingProfileForSeries(userId, seriesId, true); + + UpdateReaderProfileFields(parentProfile, dto, false); + unitOfWork.AppUserReadingProfileRepository.Update(parentProfile); + + // Remove the implicit profile when we UpdateParent (from reader) as it is implied that we are already bound with a non-implicit profile + await DeleteImplicateReadingProfilesForSeries(userId, [seriesId]); + + await unitOfWork.CommitAsync(); + return mapper.Map(parentProfile); + } + + public async Task UpdateReadingProfile(int userId, UserReadingProfileDto dto) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, dto.Id); + if (profile == null) throw new KavitaException("profile-does-not-exist"); + + UpdateReaderProfileFields(profile, dto); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + + await unitOfWork.CommitAsync(); + return mapper.Map(profile); + } + + public async Task CreateReadingProfile(int userId, UserReadingProfileDto dto) + { + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + + if (await unitOfWork.AppUserReadingProfileRepository.IsProfileNameInUse(userId, dto.Name)) throw new KavitaException("name-already-in-use"); + + var newProfile = new AppUserReadingProfileBuilder(user.Id).Build(); + UpdateReaderProfileFields(newProfile, dto); + + unitOfWork.AppUserReadingProfileRepository.Add(newProfile); + user.ReadingProfiles.Add(newProfile); + + await unitOfWork.CommitAsync(); + + return mapper.Map(newProfile); + } + + /// + /// Promotes the implicit profile to a user profile. Removes the series from other profiles. + /// + /// + /// + /// + public async Task PromoteImplicitProfile(int userId, int profileId) + { + // Get all the user's profiles including the implicit + var allUserProfiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, false); + var profileToPromote = allUserProfiles.First(r => r.Id == profileId); + var seriesId = profileToPromote.SeriesIds[0]; // An Implicit series can only be bound to 1 Series + + // Check if there are any reading profiles (Series) already bound to the series + var existingSeriesProfile = allUserProfiles.FirstOrDefault(r => r.SeriesIds.Contains(seriesId) && r.Kind == ReadingProfileKind.User); + if (existingSeriesProfile != null) + { + existingSeriesProfile.SeriesIds.Remove(seriesId); + unitOfWork.AppUserReadingProfileRepository.Update(existingSeriesProfile); + } + + // Convert the implicit profile into a proper Series + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null) throw new KavitaException("series-doesnt-exist"); // Shouldn't happen + + profileToPromote.Kind = ReadingProfileKind.User; + profileToPromote.Name = await localizationService.Translate(userId, "generated-reading-profile-name", series.Name); + profileToPromote.Name = EnsureUniqueProfileName(allUserProfiles, profileToPromote.Name); + profileToPromote.NormalizedName = profileToPromote.Name.ToNormalized(); + unitOfWork.AppUserReadingProfileRepository.Update(profileToPromote); + + await unitOfWork.CommitAsync(); + + return mapper.Map(profileToPromote); + } + + private static string EnsureUniqueProfileName(IList allUserProfiles, string name) + { + var counter = 1; + var newName = name; + while (allUserProfiles.Any(p => p.Name == newName)) + { + newName = $"{name} ({counter})"; + counter++; + } + + return newName; + } + + public async Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto) + { + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var existingProfile = profiles.FirstOrDefault(rp => rp.Kind == ReadingProfileKind.Implicit && rp.SeriesIds.Contains(seriesId)); + + // Series already had an implicit profile, update it + if (existingProfile is {Kind: ReadingProfileKind.Implicit}) + { + UpdateReaderProfileFields(existingProfile, dto, false); + unitOfWork.AppUserReadingProfileRepository.Update(existingProfile); + await unitOfWork.CommitAsync(); + + return mapper.Map(existingProfile); + } + + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId) ?? throw new KeyNotFoundException(); + var newProfile = new AppUserReadingProfileBuilder(userId) + .WithSeries(series) + .WithKind(ReadingProfileKind.Implicit) + .Build(); + + // Set name to something fitting for debugging if needed + UpdateReaderProfileFields(newProfile, dto, false); + newProfile.Name = $"Implicit Profile for {seriesId}"; + newProfile.NormalizedName = newProfile.Name.ToNormalized(); + + user.ReadingProfiles.Add(newProfile); + await unitOfWork.CommitAsync(); + + return mapper.Map(newProfile); + } + + public async Task DeleteReadingProfile(int userId, int profileId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + if (profile.Kind == ReadingProfileKind.Default) throw new KavitaException("cant-delete-default-profile"); + + unitOfWork.AppUserReadingProfileRepository.Remove(profile); + await unitOfWork.CommitAsync(); + } + + public async Task AddProfileToSeries(int userId, int profileId, int seriesId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + await DeleteImplicitAndRemoveFromUserProfiles(userId, [seriesId], []); + + profile.SeriesIds.Add(seriesId); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + + await unitOfWork.CommitAsync(); + } + + public async Task BulkAddProfileToSeries(int userId, int profileId, IList seriesIds) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + await DeleteImplicitAndRemoveFromUserProfiles(userId, seriesIds, []); + + profile.SeriesIds.AddRange(seriesIds.Except(profile.SeriesIds)); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + + await unitOfWork.CommitAsync(); + } + + public async Task ClearSeriesProfile(int userId, int seriesId) + { + await DeleteImplicitAndRemoveFromUserProfiles(userId, [seriesId], []); + await unitOfWork.CommitAsync(); + } + + public async Task AddProfileToLibrary(int userId, int profileId, int libraryId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId); + if (profile == null) throw new KavitaException("profile-doesnt-exist"); + + await DeleteImplicitAndRemoveFromUserProfiles(userId, [], [libraryId]); + + profile.LibraryIds.Add(libraryId); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + await unitOfWork.CommitAsync(); + } + + public async Task ClearLibraryProfile(int userId, int libraryId) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var libraryProfile = profiles.FirstOrDefault(p => p.LibraryIds.Contains(libraryId)); + if (libraryProfile != null) + { + libraryProfile.LibraryIds.Remove(libraryId); + unitOfWork.AppUserReadingProfileRepository.Update(libraryProfile); + } + + + if (unitOfWork.HasChanges()) + { + await unitOfWork.CommitAsync(); + } + } + + public async Task GetReadingProfileDtoForLibrary(int userId, int libraryId) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, true); + return mapper.Map(profiles.FirstOrDefault(p => p.LibraryIds.Contains(libraryId))); + } + + private async Task DeleteImplicitAndRemoveFromUserProfiles(int userId, IList seriesIds, IList libraryIds) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var implicitProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any()) + .Where(rp => rp.Kind == ReadingProfileKind.Implicit) + .ToList(); + unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles); + + var nonImplicitProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any() || rp.LibraryIds.Intersect(libraryIds).Any()) + .Where(rp => rp.Kind != ReadingProfileKind.Implicit); + + foreach (var profile in nonImplicitProfiles) + { + profile.SeriesIds.RemoveAll(seriesIds.Contains); + profile.LibraryIds.RemoveAll(libraryIds.Contains); + unitOfWork.AppUserReadingProfileRepository.Update(profile); + } + } + + private async Task DeleteImplicateReadingProfilesForSeries(int userId, IList seriesIds) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var implicitProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any()) + .Where(rp => rp.Kind == ReadingProfileKind.Implicit) + .ToList(); + unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles); + } + + private async Task RemoveSeriesFromUserProfiles(int userId, IList seriesIds) + { + var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId); + var userProfiles = profiles + .Where(rp => rp.SeriesIds.Intersect(seriesIds).Any()) + .Where(rp => rp.Kind == ReadingProfileKind.User) + .ToList(); + + unitOfWork.AppUserReadingProfileRepository.RemoveRange(userProfiles); + } + + public static void UpdateReaderProfileFields(AppUserReadingProfile existingProfile, UserReadingProfileDto dto, bool updateName = true) + { + if (updateName && !string.IsNullOrEmpty(dto.Name) && existingProfile.NormalizedName != dto.Name.ToNormalized()) + { + existingProfile.Name = dto.Name; + existingProfile.NormalizedName = dto.Name.ToNormalized(); + } + + // Manga Reader + existingProfile.ReadingDirection = dto.ReadingDirection; + existingProfile.ScalingOption = dto.ScalingOption; + existingProfile.PageSplitOption = dto.PageSplitOption; + existingProfile.ReaderMode = dto.ReaderMode; + existingProfile.AutoCloseMenu = dto.AutoCloseMenu; + existingProfile.ShowScreenHints = dto.ShowScreenHints; + existingProfile.EmulateBook = dto.EmulateBook; + existingProfile.LayoutMode = dto.LayoutMode; + existingProfile.BackgroundColor = string.IsNullOrEmpty(dto.BackgroundColor) ? "#000000" : dto.BackgroundColor; + existingProfile.SwipeToPaginate = dto.SwipeToPaginate; + existingProfile.AllowAutomaticWebtoonReaderDetection = dto.AllowAutomaticWebtoonReaderDetection; + existingProfile.WidthOverride = dto.WidthOverride; + existingProfile.DisableWidthOverride = dto.DisableWidthOverride; + + // Book Reader + existingProfile.BookReaderMargin = dto.BookReaderMargin; + existingProfile.BookReaderLineSpacing = dto.BookReaderLineSpacing; + existingProfile.BookReaderFontSize = dto.BookReaderFontSize; + existingProfile.BookReaderFontFamily = dto.BookReaderFontFamily; + existingProfile.BookReaderTapToPaginate = dto.BookReaderTapToPaginate; + existingProfile.BookReaderReadingDirection = dto.BookReaderReadingDirection; + existingProfile.BookReaderWritingStyle = dto.BookReaderWritingStyle; + existingProfile.BookThemeName = dto.BookReaderThemeName; + existingProfile.BookReaderLayoutMode = dto.BookReaderLayoutMode; + existingProfile.BookReaderImmersiveMode = dto.BookReaderImmersiveMode; + + // PDF Reading + existingProfile.PdfTheme = dto.PdfTheme; + existingProfile.PdfScrollMode = dto.PdfScrollMode; + existingProfile.PdfSpreadMode = dto.PdfSpreadMode; + } +} diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index 99d02401b..015613965 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -206,17 +206,12 @@ public class CoverDbService : ICoverDbService throw new KavitaException($"Could not grab publisher image for {publisherName}"); } - _logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize()); - // Download the publisher file using Flurl - var publisherStream = await publisherLink - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - // Create the destination file path - using var image = Image.NewFromStream(publisherStream); var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat); - image.WriteToFile(Path.Combine(_directoryService.PublisherDirectory, filename)); + _logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize()); + await DownloadImageFromUrl(publisherName, encodeFormat, publisherLink, _directoryService.PublisherDirectory); + _logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize()); return filename; @@ -302,7 +297,27 @@ public class CoverDbService : ICoverDbService .GetStreamAsync(); using var image = Image.NewFromStream(imageStream); - image.WriteToFile(targetFile); + try + { + image.WriteToFile(targetFile); + } + catch (Exception ex) + { + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.WEBP: + image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.AVIF: + image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + } return filename; } @@ -385,14 +400,13 @@ public class CoverDbService : ICoverDbService private async Task FallbackToKavitaReaderPublisher(string publisherName) { const string publisherFileName = "publishers.txt"; - var externalLink = string.Empty; var allOverrides = await GetCachedData(publisherFileName) ?? await $"{NewHost}publishers/{publisherFileName}".GetStringAsync(); // Cache immediately await CacheDataAsync(publisherFileName, allOverrides); - if (string.IsNullOrEmpty(allOverrides)) return externalLink; + if (string.IsNullOrEmpty(allOverrides)) return string.Empty; var externalFile = allOverrides .Split("\n") @@ -415,7 +429,7 @@ public class CoverDbService : ICoverDbService throw new KavitaException($"Could not grab publisher image for {publisherName}"); } - return $"{NewHost}publishers/{externalLink}"; + return $"{NewHost}publishers/{externalFile}"; } private async Task CacheDataAsync(string fileName, string? content) @@ -572,8 +586,7 @@ public class CoverDbService : ICoverDbService var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); if (choseNewImage) { - - // Don't delete series cover, unless it's an override, otherwise the first chapter cover will be null + // Don't delete the Series cover unless it is an override, otherwise the first chapter will be null if (existingPath.Contains(ImageService.GetSeriesFormat(series.Id))) { _directoryService.DeleteFiles([existingPath]); @@ -624,6 +637,7 @@ public class CoverDbService : ICoverDbService } } + // TODO: Refactor this to IHasCoverImage instead of a hard entity type public async Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false) { if (!string.IsNullOrEmpty(url)) diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 12987b18b..c8eb010b3 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1159,6 +1159,12 @@ public static partial class Parser return !string.IsNullOrEmpty(name) && SeriesAndYearRegex.IsMatch(name); } + /// + /// Parse a Year from a Comic Series: Series Name (YEAR) + /// + /// Harley Quinn (2024) returns 2024 + /// + /// public static string ParseYear(string? name) { if (string.IsNullOrEmpty(name)) return string.Empty; diff --git a/API/Startup.cs b/API/Startup.cs index cb32d1742..f57cb7d01 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -293,6 +293,9 @@ public class Startup await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger); await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger); + // v0.8.7 + await ManualMigrateReadingProfiles.Migrate(dataContext, logger); + #endregion // Update the version in the DB after all migrations are run diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index dbdfca7b8..5d612e6b7 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.6.11 + 0.8.6.15 en true diff --git a/README.md b/README.md index bff8f0f5c..ffff8d831 100644 --- a/README.md +++ b/README.md @@ -107,13 +107,10 @@ Support this project by becoming a sponsor. Your logo will show up here with a l ## Mega Sponsors -## JetBrains -Thank you to [ JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. - -* [ Rider](http://www.jetbrains.com/rider/) +## Powered By +[![JetBrains logo.](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://jb.gg/OpenSource) ### License - * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) * Copyright 2020-2024 diff --git a/UI/Web/src/_tag-card-common.scss b/UI/Web/src/_tag-card-common.scss new file mode 100644 index 000000000..07f37c2a0 --- /dev/null +++ b/UI/Web/src/_tag-card-common.scss @@ -0,0 +1,30 @@ +.tag-card { + background-color: var(--bs-card-color, #2c2c2c); + padding: 1rem; + border-radius: 12px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + transition: transform 0.2s ease, background 0.3s ease; + cursor: pointer; +} + +.tag-card:hover { + background-color: #3a3a3a; + //transform: translateY(-3px); // Cool effect but has a weird background issue. ROBBIE: Fix this +} + +.tag-name { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.5rem; + max-height: 8rem; + height: 8rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.tag-meta { + font-size: 0.85rem; + display: flex; + justify-content: space-between; + color: var(--text-muted-color, #bbb); +} diff --git a/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts index a8dc1ce06..05a4041c8 100644 --- a/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts +++ b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts @@ -1,6 +1,8 @@ import {MatchStateOption} from "./match-state-option"; +import {LibraryType} from "../library/library"; export interface ManageMatchFilter { matchStateOption: MatchStateOption; + libraryType: LibraryType | -1; searchTerm: string; } diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index 06ba86cf2..bad83f54b 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -13,6 +13,8 @@ export enum LibraryType { } export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images]; +export const allKavitaPlusMetadataApplicableTypes = [LibraryType.Manga, LibraryType.LightNovel, LibraryType.ComicVine, LibraryType.Comic]; +export const allKavitaPlusScrobbleEligibleTypes = [LibraryType.Manga, LibraryType.LightNovel]; export interface Library { id: number; diff --git a/UI/Web/src/app/_models/metadata/browse/browse-genre.ts b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts new file mode 100644 index 000000000..e7bb0d915 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/browse/browse-genre.ts @@ -0,0 +1,6 @@ +import {Genre} from "../genre"; + +export interface BrowseGenre extends Genre { + seriesCount: number; + chapterCount: number; +} diff --git a/UI/Web/src/app/_models/person/browse-person.ts b/UI/Web/src/app/_models/metadata/browse/browse-person.ts similarity index 52% rename from UI/Web/src/app/_models/person/browse-person.ts rename to UI/Web/src/app/_models/metadata/browse/browse-person.ts index aeddac7cd..886f9455b 100644 --- a/UI/Web/src/app/_models/person/browse-person.ts +++ b/UI/Web/src/app/_models/metadata/browse/browse-person.ts @@ -1,6 +1,6 @@ -import {Person} from "../metadata/person"; +import {Person} from "../person"; export interface BrowsePerson extends Person { seriesCount: number; - issueCount: number; + chapterCount: number; } diff --git a/UI/Web/src/app/_models/metadata/browse/browse-tag.ts b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts new file mode 100644 index 000000000..4d87370ee --- /dev/null +++ b/UI/Web/src/app/_models/metadata/browse/browse-tag.ts @@ -0,0 +1,6 @@ +import {Tag} from "../../tag"; + +export interface BrowseTag extends Tag { + seriesCount: number; + chapterCount: number; +} diff --git a/UI/Web/src/app/_models/metadata/language.ts b/UI/Web/src/app/_models/metadata/language.ts index 8b68c7233..28ab2b598 100644 --- a/UI/Web/src/app/_models/metadata/language.ts +++ b/UI/Web/src/app/_models/metadata/language.ts @@ -4,7 +4,10 @@ export interface Language { } export interface KavitaLocale { - fileName: string; // isoCode aka what maps to the file on disk and what transloco loads + /** + * isoCode aka what maps to the file on disk and what transloco loads + */ + fileName: string; renderName: string; translationCompletion: number; isRtL: boolean; diff --git a/UI/Web/src/app/_models/metadata/person.ts b/UI/Web/src/app/_models/metadata/person.ts index 6b098de19..efc8df914 100644 --- a/UI/Web/src/app/_models/metadata/person.ts +++ b/UI/Web/src/app/_models/metadata/person.ts @@ -2,7 +2,6 @@ import {IHasCover} from "../common/i-has-cover"; export enum PersonRole { Other = 1, - Artist = 2, Writer = 3, Penciller = 4, Inker = 5, @@ -32,3 +31,22 @@ export interface Person extends IHasCover { primaryColor: string; secondaryColor: string; } + +/** + * Excludes Other as it's not in use + */ +export const allPeopleRoles = [ + PersonRole.Writer, + PersonRole.Penciller, + PersonRole.Inker, + PersonRole.Colorist, + PersonRole.Letterer, + PersonRole.CoverArtist, + PersonRole.Editor, + PersonRole.Publisher, + PersonRole.Character, + PersonRole.Translator, + PersonRole.Imprint, + PersonRole.Team, + PersonRole.Location +] diff --git a/UI/Web/src/app/_models/metadata/series-filter.ts b/UI/Web/src/app/_models/metadata/series-filter.ts index 7d043aa3c..7875732b7 100644 --- a/UI/Web/src/app/_models/metadata/series-filter.ts +++ b/UI/Web/src/app/_models/metadata/series-filter.ts @@ -1,5 +1,5 @@ import {MangaFormat} from "../manga-format"; -import {SeriesFilterV2} from "./v2/series-filter-v2"; +import {FilterV2} from "./v2/filter-v2"; export interface FilterItem { title: string; @@ -7,10 +7,6 @@ export interface FilterItem { selected: boolean; } -export interface SortOptions { - sortField: SortField; - isAscending: boolean; -} export enum SortField { SortName = 1, @@ -27,7 +23,7 @@ export enum SortField { Random = 9 } -export const allSortFields = Object.keys(SortField) +export const allSeriesSortFields = Object.keys(SortField) .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) .map(key => parseInt(key, 10)) as SortField[]; @@ -54,8 +50,8 @@ export const mangaFormatFilters = [ } ]; -export interface FilterEvent { - filterV2: SeriesFilterV2; +export interface FilterEvent { + filterV2: FilterV2; isFirst: boolean; } diff --git a/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts b/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts new file mode 100644 index 000000000..bb5edc9ce --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/browse-person-filter.ts @@ -0,0 +1,8 @@ +import {PersonRole} from "../person"; +import {PersonSortOptions} from "./sort-options"; + +export interface BrowsePersonFilter { + roles: Array; + query?: string; + sortOptions?: PersonSortOptions; +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts index 08005d5c8..eeb8c7853 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -48,7 +48,7 @@ const enumArray = Object.keys(FilterField) enumArray.sort((a, b) => a.value.localeCompare(b.value)); -export const allFields = enumArray +export const allSeriesFilterFields = enumArray .map(key => parseInt(key.key, 10))as FilterField[]; export const allPeople = [ @@ -66,7 +66,6 @@ export const allPeople = [ export const personRoleForFilterField = (role: PersonRole) => { switch (role) { - case PersonRole.Artist: return FilterField.CoverArtist; case PersonRole.Character: return FilterField.Characters; case PersonRole.Colorist: return FilterField.Colorist; case PersonRole.CoverArtist: return FilterField.CoverArtist; diff --git a/UI/Web/src/app/_models/metadata/v2/filter-statement.ts b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts index d031927a2..b14fe564d 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-statement.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-statement.ts @@ -1,8 +1,7 @@ -import { FilterComparison } from "./filter-comparison"; -import { FilterField } from "./filter-field"; +import {FilterComparison} from "./filter-comparison"; -export interface FilterStatement { +export interface FilterStatement { comparison: FilterComparison; - field: FilterField; + field: T; value: string; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/_models/metadata/v2/filter-v2.ts b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts new file mode 100644 index 000000000..77c064450 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/filter-v2.ts @@ -0,0 +1,11 @@ +import {FilterStatement} from "./filter-statement"; +import {FilterCombination} from "./filter-combination"; +import {SortOptions} from "./sort-options"; + +export interface FilterV2 { + name?: string; + statements: Array>; + combination: FilterCombination; + sortOptions?: SortOptions; + limitTo: number; +} diff --git a/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts new file mode 100644 index 000000000..6bfb5a0c1 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/person-filter-field.ts @@ -0,0 +1,12 @@ +export enum PersonFilterField { + Role = 1, + Name = 2, + SeriesCount = 3, + ChapterCount = 4, +} + + +export const allPersonFilterFields = Object.keys(PersonFilterField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as PersonFilterField[]; + diff --git a/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts b/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts new file mode 100644 index 000000000..6bcb66925 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/person-sort-field.ts @@ -0,0 +1,9 @@ +export enum PersonSortField { + Name = 1, + SeriesCount = 2, + ChapterCount = 3 +} + +export const allPersonSortFields = Object.keys(PersonSortField) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as PersonSortField[]; diff --git a/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts b/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts deleted file mode 100644 index c13244644..000000000 --- a/UI/Web/src/app/_models/metadata/v2/series-filter-v2.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SortOptions } from "../series-filter"; -import {FilterStatement} from "./filter-statement"; -import {FilterCombination} from "./filter-combination"; - -export interface SeriesFilterV2 { - name?: string; - statements: Array; - combination: FilterCombination; - sortOptions?: SortOptions; - limitTo: number; -} diff --git a/UI/Web/src/app/_models/metadata/v2/sort-options.ts b/UI/Web/src/app/_models/metadata/v2/sort-options.ts new file mode 100644 index 000000000..ed68d6b9d --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/sort-options.ts @@ -0,0 +1,17 @@ +import {PersonSortField} from "./person-sort-field"; + +/** + * Series-based Sort options + */ +export interface SortOptions { + sortField: TSort; + isAscending: boolean; +} + +/** + * Person-based Sort Options + */ +export interface PersonSortOptions { + sortField: PersonSortField; + isAscending: boolean; +} diff --git a/UI/Web/src/app/_models/preferences/book-theme.ts b/UI/Web/src/app/_models/preferences/book-theme.ts index b6e37f6e4..cb321c110 100644 --- a/UI/Web/src/app/_models/preferences/book-theme.ts +++ b/UI/Web/src/app/_models/preferences/book-theme.ts @@ -1,7 +1,7 @@ -import { ThemeProvider } from "./site-theme"; +import {ThemeProvider} from "./site-theme"; /** - * Theme for the the book reader contents + * Theme for the book reader contents */ export interface BookTheme { name: string; diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 1dd5731e5..886c570e2 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,47 +1,7 @@ -import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode'; -import {BookPageLayoutMode} from '../readers/book-page-layout-mode'; import {PageLayoutMode} from '../page-layout-mode'; -import {PageSplitOption} from './page-split-option'; -import {ReaderMode} from './reader-mode'; -import {ReadingDirection} from './reading-direction'; -import {ScalingOption} from './scaling-option'; import {SiteTheme} from './site-theme'; -import {WritingStyle} from "./writing-style"; -import {PdfTheme} from "./pdf-theme"; -import {PdfScrollMode} from "./pdf-scroll-mode"; -import {PdfLayoutMode} from "./pdf-layout-mode"; -import {PdfSpreadMode} from "./pdf-spread-mode"; export interface Preferences { - // Manga Reader - readingDirection: ReadingDirection; - scalingOption: ScalingOption; - pageSplitOption: PageSplitOption; - readerMode: ReaderMode; - autoCloseMenu: boolean; - layoutMode: LayoutMode; - backgroundColor: string; - showScreenHints: boolean; - emulateBook: boolean; - swipeToPaginate: boolean; - allowAutomaticWebtoonReaderDetection: boolean; - - // Book Reader - bookReaderMargin: number; - bookReaderLineSpacing: number; - bookReaderFontSize: number; - bookReaderFontFamily: string; - bookReaderTapToPaginate: boolean; - bookReaderReadingDirection: ReadingDirection; - bookReaderWritingStyle: WritingStyle; - bookReaderThemeName: string; - bookReaderLayoutMode: BookPageLayoutMode; - bookReaderImmersiveMode: boolean; - - // PDF Reader - pdfTheme: PdfTheme; - pdfScrollMode: PdfScrollMode; - pdfSpreadMode: PdfSpreadMode; // Global theme: SiteTheme; @@ -58,15 +18,3 @@ export interface Preferences { wantToReadSync: boolean; } -export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; -export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}]; -export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}]; -export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}]; -export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}]; -export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} -export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}]; -export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}]; -export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}]; -export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; -export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; -export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; diff --git a/UI/Web/src/app/_models/preferences/reading-profiles.ts b/UI/Web/src/app/_models/preferences/reading-profiles.ts new file mode 100644 index 000000000..dad02946f --- /dev/null +++ b/UI/Web/src/app/_models/preferences/reading-profiles.ts @@ -0,0 +1,80 @@ +import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode'; +import {BookPageLayoutMode} from '../readers/book-page-layout-mode'; +import {PageLayoutMode} from '../page-layout-mode'; +import {PageSplitOption} from './page-split-option'; +import {ReaderMode} from './reader-mode'; +import {ReadingDirection} from './reading-direction'; +import {ScalingOption} from './scaling-option'; +import {WritingStyle} from "./writing-style"; +import {PdfTheme} from "./pdf-theme"; +import {PdfScrollMode} from "./pdf-scroll-mode"; +import {PdfLayoutMode} from "./pdf-layout-mode"; +import {PdfSpreadMode} from "./pdf-spread-mode"; +import {Series} from "../series"; +import {Library} from "../library/library"; +import {UserBreakpoint} from "../../shared/_services/utility.service"; + +export enum ReadingProfileKind { + Default = 0, + User = 1, + Implicit = 2, +} + +export interface ReadingProfile { + + id: number; + name: string; + normalizedName: string; + kind: ReadingProfileKind; + + // Manga Reader + readingDirection: ReadingDirection; + scalingOption: ScalingOption; + pageSplitOption: PageSplitOption; + readerMode: ReaderMode; + autoCloseMenu: boolean; + layoutMode: LayoutMode; + backgroundColor: string; + showScreenHints: boolean; + emulateBook: boolean; + swipeToPaginate: boolean; + allowAutomaticWebtoonReaderDetection: boolean; + widthOverride?: number; + disableWidthOverride: UserBreakpoint; + + // Book Reader + bookReaderMargin: number; + bookReaderLineSpacing: number; + bookReaderFontSize: number; + bookReaderFontFamily: string; + bookReaderTapToPaginate: boolean; + bookReaderReadingDirection: ReadingDirection; + bookReaderWritingStyle: WritingStyle; + bookReaderThemeName: string; + bookReaderLayoutMode: BookPageLayoutMode; + bookReaderImmersiveMode: boolean; + + // PDF Reader + pdfTheme: PdfTheme; + pdfScrollMode: PdfScrollMode; + pdfSpreadMode: PdfSpreadMode; + + // relations + seriesIds: number[]; + libraryIds: number[]; + +} + +export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; +export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}]; +export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}]; +export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}]; +export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}]; +export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} +export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}]; +export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}]; +export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}]; +export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; +export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; +export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; +export const breakPoints = [UserBreakpoint.Never, UserBreakpoint.Mobile, UserBreakpoint.Tablet, UserBreakpoint.Desktop] diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts index 21b669f0c..a01267cf3 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -20,5 +20,6 @@ export enum WikiLink { UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native', UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker', OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients', - Guides = 'https://wiki.kavitareader.com/guides' + Guides = 'https://wiki.kavitareader.com/guides', + ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/", } diff --git a/UI/Web/src/app/_pipes/breakpoint.pipe.ts b/UI/Web/src/app/_pipes/breakpoint.pipe.ts new file mode 100644 index 000000000..1897b773c --- /dev/null +++ b/UI/Web/src/app/_pipes/breakpoint.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {UserBreakpoint} from "../shared/_services/utility.service"; + +@Pipe({ + name: 'breakpoint' +}) +export class BreakpointPipe implements PipeTransform { + + transform(value: UserBreakpoint): string { + const v = parseInt(value + '', 10) as UserBreakpoint; + switch (v) { + case UserBreakpoint.Never: + return translate('breakpoint-pipe.never'); + case UserBreakpoint.Mobile: + return translate('breakpoint-pipe.mobile'); + case UserBreakpoint.Tablet: + return translate('breakpoint-pipe.tablet'); + case UserBreakpoint.Desktop: + return translate('breakpoint-pipe.desktop'); + } + throw new Error("unknown breakpoint value: " + value); + } + +} diff --git a/UI/Web/src/app/_pipes/browse-title.pipe.ts b/UI/Web/src/app/_pipes/browse-title.pipe.ts new file mode 100644 index 000000000..0495e8b8a --- /dev/null +++ b/UI/Web/src/app/_pipes/browse-title.pipe.ts @@ -0,0 +1,78 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {FilterField} from "../_models/metadata/v2/filter-field"; +import {translate} from "@jsverse/transloco"; + +/** + * Responsible for taking a filter field and value (as a string) and translating into a "Browse X" heading for All Series page + * Example: Genre & "Action" -> Browse Action + * Example: Artist & "Joe Shmo" -> Browse Joe Shmo Works + */ +@Pipe({ + name: 'browseTitle' +}) +export class BrowseTitlePipe implements PipeTransform { + + transform(field: FilterField, value: string): string { + switch (field) { + case FilterField.PublicationStatus: + return translate('browse-title-pipe.publication-status', {value}); + case FilterField.AgeRating: + return translate('browse-title-pipe.age-rating', {value}); + case FilterField.UserRating: + return translate('browse-title-pipe.user-rating', {value}); + case FilterField.Tags: + return translate('browse-title-pipe.tag', {value}); + case FilterField.Translators: + return translate('browse-title-pipe.translator', {value}); + case FilterField.Characters: + return translate('browse-title-pipe.character', {value}); + case FilterField.Publisher: + return translate('browse-title-pipe.publisher', {value}); + case FilterField.Editor: + return translate('browse-title-pipe.editor', {value}); + case FilterField.CoverArtist: + return translate('browse-title-pipe.artist', {value}); + case FilterField.Letterer: + return translate('browse-title-pipe.letterer', {value}); + case FilterField.Colorist: + return translate('browse-title-pipe.colorist', {value}); + case FilterField.Inker: + return translate('browse-title-pipe.inker', {value}); + case FilterField.Penciller: + return translate('browse-title-pipe.penciller', {value}); + case FilterField.Writers: + return translate('browse-title-pipe.writer', {value}); + case FilterField.Genres: + return translate('browse-title-pipe.genre', {value}); + case FilterField.Libraries: + return translate('browse-title-pipe.library', {value}); + case FilterField.Formats: + return translate('browse-title-pipe.format', {value}); + case FilterField.ReleaseYear: + return translate('browse-title-pipe.release-year', {value}); + case FilterField.Imprint: + return translate('browse-title-pipe.imprint', {value}); + case FilterField.Team: + return translate('browse-title-pipe.team', {value}); + case FilterField.Location: + return translate('browse-title-pipe.location', {value}); + + // These have no natural links in the app to demand a richer title experience + case FilterField.Languages: + case FilterField.CollectionTags: + case FilterField.ReadProgress: + case FilterField.ReadTime: + case FilterField.Path: + case FilterField.FilePath: + case FilterField.WantToRead: + case FilterField.ReadingDate: + case FilterField.AverageRating: + case FilterField.ReadLast: + case FilterField.Summary: + case FilterField.SeriesName: + default: + return ''; + } + } + +} diff --git a/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts new file mode 100644 index 000000000..f342c0034 --- /dev/null +++ b/UI/Web/src/app/_pipes/generic-filter-field.pipe.ts @@ -0,0 +1,108 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {FilterField} from "../_models/metadata/v2/filter-field"; +import {translate} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; + +@Pipe({ + name: 'genericFilterField' +}) +export class GenericFilterFieldPipe implements PipeTransform { + + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case "series": + return this.translateFilterField(value as FilterField); + case "person": + return this.translatePersonFilterField(value as PersonFilterField); + } + } + + private translatePersonFilterField(value: PersonFilterField) { + switch (value) { + case PersonFilterField.Role: + return translate('generic-filter-field-pipe.person-role'); + case PersonFilterField.Name: + return translate('generic-filter-field-pipe.person-name'); + case PersonFilterField.SeriesCount: + return translate('generic-filter-field-pipe.person-series-count'); + case PersonFilterField.ChapterCount: + return translate('generic-filter-field-pipe.person-chapter-count'); + } + } + + private translateFilterField(value: FilterField) { + switch (value) { + case FilterField.AgeRating: + return translate('filter-field-pipe.age-rating'); + case FilterField.Characters: + return translate('filter-field-pipe.characters'); + case FilterField.CollectionTags: + return translate('filter-field-pipe.collection-tags'); + case FilterField.Colorist: + return translate('filter-field-pipe.colorist'); + case FilterField.CoverArtist: + return translate('filter-field-pipe.cover-artist'); + case FilterField.Editor: + return translate('filter-field-pipe.editor'); + case FilterField.Formats: + return translate('filter-field-pipe.formats'); + case FilterField.Genres: + return translate('filter-field-pipe.genres'); + case FilterField.Inker: + return translate('filter-field-pipe.inker'); + case FilterField.Imprint: + return translate('filter-field-pipe.imprint'); + case FilterField.Team: + return translate('filter-field-pipe.team'); + case FilterField.Location: + return translate('filter-field-pipe.location'); + case FilterField.Languages: + return translate('filter-field-pipe.languages'); + case FilterField.Libraries: + return translate('filter-field-pipe.libraries'); + case FilterField.Letterer: + return translate('filter-field-pipe.letterer'); + case FilterField.PublicationStatus: + return translate('filter-field-pipe.publication-status'); + case FilterField.Penciller: + return translate('filter-field-pipe.penciller'); + case FilterField.Publisher: + return translate('filter-field-pipe.publisher'); + case FilterField.ReadProgress: + return translate('filter-field-pipe.read-progress'); + case FilterField.ReadTime: + return translate('filter-field-pipe.read-time'); + case FilterField.ReleaseYear: + return translate('filter-field-pipe.release-year'); + case FilterField.SeriesName: + return translate('filter-field-pipe.series-name'); + case FilterField.Summary: + return translate('filter-field-pipe.summary'); + case FilterField.Tags: + return translate('filter-field-pipe.tags'); + case FilterField.Translators: + return translate('filter-field-pipe.translators'); + case FilterField.UserRating: + return translate('filter-field-pipe.user-rating'); + case FilterField.Writers: + return translate('filter-field-pipe.writers'); + case FilterField.Path: + return translate('filter-field-pipe.path'); + case FilterField.FilePath: + return translate('filter-field-pipe.file-path'); + case FilterField.WantToRead: + return translate('filter-field-pipe.want-to-read'); + case FilterField.ReadingDate: + return translate('filter-field-pipe.read-date'); + case FilterField.ReadLast: + return translate('filter-field-pipe.read-last'); + case FilterField.AverageRating: + return translate('filter-field-pipe.average-rating'); + default: + throw new Error(`Invalid FilterField value: ${value}`); + } + } + +} diff --git a/UI/Web/src/app/_pipes/person-role.pipe.ts b/UI/Web/src/app/_pipes/person-role.pipe.ts index c1395ae5b..1b9ee2163 100644 --- a/UI/Web/src/app/_pipes/person-role.pipe.ts +++ b/UI/Web/src/app/_pipes/person-role.pipe.ts @@ -1,6 +1,6 @@ -import {inject, Pipe, PipeTransform} from '@angular/core'; -import { PersonRole } from '../_models/metadata/person'; -import {translate, TranslocoService} from "@jsverse/transloco"; +import {Pipe, PipeTransform} from '@angular/core'; +import {PersonRole} from '../_models/metadata/person'; +import {translate} from "@jsverse/transloco"; @Pipe({ name: 'personRole', @@ -10,8 +10,6 @@ export class PersonRolePipe implements PipeTransform { transform(value: PersonRole): string { switch (value) { - case PersonRole.Artist: - return translate('person-role-pipe.artist'); case PersonRole.Character: return translate('person-role-pipe.character'); case PersonRole.Colorist: diff --git a/UI/Web/src/app/_pipes/sort-field.pipe.ts b/UI/Web/src/app/_pipes/sort-field.pipe.ts index 13ff4f758..d032de9c8 100644 --- a/UI/Web/src/app/_pipes/sort-field.pipe.ts +++ b/UI/Web/src/app/_pipes/sort-field.pipe.ts @@ -1,6 +1,8 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import {Pipe, PipeTransform} from '@angular/core'; import {SortField} from "../_models/metadata/series-filter"; import {TranslocoService} from "@jsverse/transloco"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Pipe({ name: 'sortField', @@ -11,7 +13,30 @@ export class SortFieldPipe implements PipeTransform { constructor(private translocoService: TranslocoService) { } - transform(value: SortField): string { + transform(value: T, entityType: ValidFilterEntity): string { + + switch (entityType) { + case 'series': + return this.seriesSortFields(value as SortField); + case 'person': + return this.personSortFields(value as PersonSortField); + + } + } + + private personSortFields(value: PersonSortField) { + switch (value) { + case PersonSortField.Name: + return this.translocoService.translate('sort-field-pipe.person-name'); + case PersonSortField.SeriesCount: + return this.translocoService.translate('sort-field-pipe.person-series-count'); + case PersonSortField.ChapterCount: + return this.translocoService.translate('sort-field-pipe.person-chapter-count'); + + } + } + + private seriesSortFields(value: SortField) { switch (value) { case SortField.SortName: return this.translocoService.translate('sort-field-pipe.sort-name'); @@ -32,7 +57,6 @@ export class SortFieldPipe implements PipeTransform { case SortField.Random: return this.translocoService.translate('sort-field-pipe.random'); } - } } diff --git a/UI/Web/src/app/_resolvers/reading-profile.resolver.ts b/UI/Web/src/app/_resolvers/reading-profile.resolver.ts new file mode 100644 index 000000000..1d28adf95 --- /dev/null +++ b/UI/Web/src/app/_resolvers/reading-profile.resolver.ts @@ -0,0 +1,18 @@ +import {Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router'; +import {Observable} from 'rxjs'; +import {ReadingProfileService} from "../_services/reading-profile.service"; + +@Injectable({ + providedIn: 'root' +}) +export class ReadingProfileResolver implements Resolve { + + constructor(private readingProfileService: ReadingProfileService) {} + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + // Extract seriesId from route params or parent route + const seriesId = route.params['seriesId'] || route.parent?.params['seriesId']; + return this.readingProfileService.getForSeries(seriesId); + } +} diff --git a/UI/Web/src/app/_resolvers/url-filter.resolver.ts b/UI/Web/src/app/_resolvers/url-filter.resolver.ts new file mode 100644 index 000000000..16bc5c752 --- /dev/null +++ b/UI/Web/src/app/_resolvers/url-filter.resolver.ts @@ -0,0 +1,22 @@ +import {Injectable} from "@angular/core"; +import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from "@angular/router"; +import {Observable, of} from "rxjs"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; + +/** + * Checks the url for a filter and resolves one if applicable, otherwise returns null. + * It is up to the consumer to cast appropriately. + */ +@Injectable({ + providedIn: 'root' +}) +export class UrlFilterResolver implements Resolve { + + constructor(private filterUtilitiesService: FilterUtilitiesService) {} + + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + if (!state.url.includes('?')) return of(null); + return this.filterUtilitiesService.decodeFilter(state.url.split('?')[1]); + } +} diff --git a/UI/Web/src/app/_routes/all-series-routing.module.ts b/UI/Web/src/app/_routes/all-series-routing.module.ts index d9dfaaf96..5c4804251 100644 --- a/UI/Web/src/app/_routes/all-series-routing.module.ts +++ b/UI/Web/src/app/_routes/all-series-routing.module.ts @@ -1,7 +1,13 @@ -import { Routes } from "@angular/router"; -import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; +import {Routes} from "@angular/router"; +import {AllSeriesComponent} from "../all-series/_components/all-series/all-series.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: AllSeriesComponent, pathMatch: 'full'}, + {path: '', component: AllSeriesComponent, pathMatch: 'full', + runGuardsAndResolvers: 'always', + resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_routes/book-reader.router.module.ts b/UI/Web/src/app/_routes/book-reader.router.module.ts index 5083c2d4a..c9d6262ad 100644 --- a/UI/Web/src/app/_routes/book-reader.router.module.ts +++ b/UI/Web/src/app/_routes/book-reader.router.module.ts @@ -1,10 +1,14 @@ -import { Routes } from '@angular/router'; -import { BookReaderComponent } from '../book-reader/_components/book-reader/book-reader.component'; +import {Routes} from '@angular/router'; +import {BookReaderComponent} from '../book-reader/_components/book-reader/book-reader.component'; +import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver"; export const routes: Routes = [ { path: ':chapterId', component: BookReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } } ]; diff --git a/UI/Web/src/app/_routes/bookmark-routing.module.ts b/UI/Web/src/app/_routes/bookmark-routing.module.ts index 6da971e08..2c7c52036 100644 --- a/UI/Web/src/app/_routes/bookmark-routing.module.ts +++ b/UI/Web/src/app/_routes/bookmark-routing.module.ts @@ -1,6 +1,12 @@ -import { Routes } from "@angular/router"; -import { BookmarksComponent } from "../bookmark/_components/bookmarks/bookmarks.component"; +import {Routes} from "@angular/router"; +import {BookmarksComponent} from "../bookmark/_components/bookmarks/bookmarks.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: BookmarksComponent, pathMatch: 'full'}, + {path: '', component: BookmarksComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/browse-authors-routing.module.ts b/UI/Web/src/app/_routes/browse-authors-routing.module.ts deleted file mode 100644 index e7aab1b57..000000000 --- a/UI/Web/src/app/_routes/browse-authors-routing.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Routes } from "@angular/router"; -import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; -import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component"; - - -export const routes: Routes = [ - {path: '', component: BrowseAuthorsComponent, pathMatch: 'full'}, -]; diff --git a/UI/Web/src/app/_routes/browse-routing.module.ts b/UI/Web/src/app/_routes/browse-routing.module.ts new file mode 100644 index 000000000..be96e8193 --- /dev/null +++ b/UI/Web/src/app/_routes/browse-routing.module.ts @@ -0,0 +1,24 @@ +import {Routes} from "@angular/router"; +import {BrowsePeopleComponent} from "../browse/browse-people/browse-people.component"; +import {BrowseGenresComponent} from "../browse/browse-genres/browse-genres.component"; +import {BrowseTagsComponent} from "../browse/browse-tags/browse-tags.component"; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; + + +export const routes: Routes = [ + // Legacy route + {path: 'authors', component: BrowsePeopleComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, + {path: 'people', component: BrowsePeopleComponent, pathMatch: 'full', + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, + {path: 'genres', component: BrowseGenresComponent, pathMatch: 'full'}, + {path: 'tags', component: BrowseTagsComponent, pathMatch: 'full'}, +]; diff --git a/UI/Web/src/app/_routes/collections-routing.module.ts b/UI/Web/src/app/_routes/collections-routing.module.ts index 80510c8f6..2b3b0ffd7 100644 --- a/UI/Web/src/app/_routes/collections-routing.module.ts +++ b/UI/Web/src/app/_routes/collections-routing.module.ts @@ -1,9 +1,15 @@ -import { Routes } from '@angular/router'; -import { AllCollectionsComponent } from '../collections/_components/all-collections/all-collections.component'; -import { CollectionDetailComponent } from '../collections/_components/collection-detail/collection-detail.component'; +import {Routes} from '@angular/router'; +import {AllCollectionsComponent} from '../collections/_components/all-collections/all-collections.component'; +import {CollectionDetailComponent} from '../collections/_components/collection-detail/collection-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ {path: '', component: AllCollectionsComponent, pathMatch: 'full'}, - {path: ':id', component: CollectionDetailComponent}, + {path: ':id', component: CollectionDetailComponent, + resolve: { + filter: UrlFilterResolver + }, + runGuardsAndResolvers: 'always', + }, ]; diff --git a/UI/Web/src/app/_routes/library-detail-routing.module.ts b/UI/Web/src/app/_routes/library-detail-routing.module.ts index 04cb3c9dd..3c09a71ee 100644 --- a/UI/Web/src/app/_routes/library-detail-routing.module.ts +++ b/UI/Web/src/app/_routes/library-detail-routing.module.ts @@ -1,7 +1,8 @@ -import { Routes } from '@angular/router'; -import { AuthGuard } from '../_guards/auth.guard'; -import { LibraryAccessGuard } from '../_guards/library-access.guard'; -import { LibraryDetailComponent } from '../library-detail/library-detail.component'; +import {Routes} from '@angular/router'; +import {AuthGuard} from '../_guards/auth.guard'; +import {LibraryAccessGuard} from '../_guards/library-access.guard'; +import {LibraryDetailComponent} from '../library-detail/library-detail.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ @@ -9,12 +10,18 @@ export const routes: Routes = [ path: ':libraryId', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, }, { path: '', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], - component: LibraryDetailComponent - } + component: LibraryDetailComponent, + resolve: { + filter: UrlFilterResolver + }, + }, ]; diff --git a/UI/Web/src/app/_routes/manga-reader.router.module.ts b/UI/Web/src/app/_routes/manga-reader.router.module.ts index 04ff77b3c..e479e8ae6 100644 --- a/UI/Web/src/app/_routes/manga-reader.router.module.ts +++ b/UI/Web/src/app/_routes/manga-reader.router.module.ts @@ -1,15 +1,22 @@ -import { Routes } from '@angular/router'; -import { MangaReaderComponent } from '../manga-reader/_components/manga-reader/manga-reader.component'; +import {Routes} from '@angular/router'; +import {MangaReaderComponent} from '../manga-reader/_components/manga-reader/manga-reader.component'; +import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver"; export const routes: Routes = [ { path: ':chapterId', - component: MangaReaderComponent + component: MangaReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } }, { // This will allow the MangaReader to have a list to use for next/prev chapters rather than natural sort order path: ':chapterId/list/:listId', - component: MangaReaderComponent + component: MangaReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } } ]; diff --git a/UI/Web/src/app/_routes/pdf-reader.router.module.ts b/UI/Web/src/app/_routes/pdf-reader.router.module.ts index a55699280..7cb9f68e2 100644 --- a/UI/Web/src/app/_routes/pdf-reader.router.module.ts +++ b/UI/Web/src/app/_routes/pdf-reader.router.module.ts @@ -1,9 +1,13 @@ -import { Routes } from '@angular/router'; -import { PdfReaderComponent } from '../pdf-reader/_components/pdf-reader/pdf-reader.component'; +import {Routes} from '@angular/router'; +import {PdfReaderComponent} from '../pdf-reader/_components/pdf-reader/pdf-reader.component'; +import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver"; export const routes: Routes = [ { path: ':chapterId', component: PdfReaderComponent, + resolve: { + readingProfile: ReadingProfileResolver + } } ]; diff --git a/UI/Web/src/app/_routes/want-to-read-routing.module.ts b/UI/Web/src/app/_routes/want-to-read-routing.module.ts index b3301d9f9..b593172c0 100644 --- a/UI/Web/src/app/_routes/want-to-read-routing.module.ts +++ b/UI/Web/src/app/_routes/want-to-read-routing.module.ts @@ -1,6 +1,10 @@ -import { Routes } from '@angular/router'; -import { WantToReadComponent } from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {Routes} from '@angular/router'; +import {WantToReadComponent} from '../want-to-read/_components/want-to-read/want-to-read.component'; +import {UrlFilterResolver} from "../_resolvers/url-filter.resolver"; export const routes: Routes = [ - {path: '', component: WantToReadComponent, pathMatch: 'full'}, + {path: '', component: WantToReadComponent, pathMatch: 'full', runGuardsAndResolvers: 'always', resolve: { + filter: UrlFilterResolver + } + }, ]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 6b8cdc243..f1f91143f 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -102,11 +102,22 @@ export class AccountService { return true; } + /** + * If the user has any role in the restricted roles array or is an Admin + * @param user + * @param roles + * @param restrictedRoles + */ hasAnyRole(user: User, roles: Array, restrictedRoles: Array = []) { if (!user || !user.roles) { return false; } + // If the user is an admin, they have the role + if (this.hasAdminRole(user)) { + return true; + } + // If restricted roles are provided and the user has any of them, deny access if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { return false; @@ -121,6 +132,33 @@ export class AccountService { return roles.some(role => user.roles.includes(role)); } + /** + * If User or Admin, will return false + * @param user + * @param restrictedRoles + */ + hasAnyRestrictedRole(user: User, restrictedRoles: Array = []) { + if (!user || !user.roles) { + return true; + } + + if (restrictedRoles.length === 0) { + return false; + } + + // If the user is an admin, they have the role + if (this.hasAdminRole(user)) { + return false; + } + + + if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { + return true; + } + + return false; + } + hasAdminRole(user: User) { return user && user.roles.includes(Role.Admin); } diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 61fee39ec..e5967bf24 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -122,6 +122,14 @@ export enum Action { * Merge two (or more?) entities */ Merge = 29, + /** + * Add to a reading profile + */ + SetReadingProfile = 30, + /** + * Remove the reading profile from the entity + */ + ClearReadingProfile = 31, } /** @@ -342,6 +350,37 @@ export class ActionFactoryService { requiredRoles: [Role.Admin], children: [], }, + { + action: Action.Submenu, + title: 'reading-profiles', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [ + { + action: Action.SetReadingProfile, + title: 'set-reading-profile', + description: 'set-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + { + action: Action.ClearReadingProfile, + title: 'clear-reading-profile', + description: 'clear-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + ], + }, { action: Action.Submenu, title: 'others', @@ -528,7 +567,7 @@ export class ActionFactoryService { requiresAdmin: false, requiredRoles: [], children: [], - }, + } ], }, { @@ -555,6 +594,37 @@ export class ActionFactoryService { } ], }, + { + action: Action.Submenu, + title: 'reading-profiles', + description: '', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [ + { + action: Action.SetReadingProfile, + title: 'set-reading-profile', + description: 'set-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + { + action: Action.ClearReadingProfile, + title: 'clear-reading-profile', + description: 'clear-reading-profile-tooltip', + callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, + requiresAdmin: false, + requiredRoles: [], + children: [], + }, + ], + }, { action: Action.Submenu, title: 'others', @@ -1047,7 +1117,10 @@ export class ActionFactoryService { if (action.children === null || action.children?.length === 0) return; - action.children?.forEach((childAction) => { + // Ensure action children are a copy of the parent (since parent does a shallow mapping) + action.children = action.children.map(d => { return {...d}; }); + + action.children.forEach((childAction) => { this.applyCallback(childAction, callback, shouldRenderFunc); }); } @@ -1055,10 +1128,13 @@ export class ActionFactoryService { public applyCallbackToList(list: Array>, callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender): Array> { + // Create a clone of the list to ensure we aren't affecting the default state const actions = list.map((a) => { return { ...a }; }); + actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc)); + return actions; } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 37826b0e2..2328bf72e 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -31,6 +31,9 @@ import {ChapterService} from "./chapter.service"; import {VolumeService} from "./volume.service"; import {DefaultModalOptions} from "../_models/default-modal-options"; import {MatchSeriesModalComponent} from "../_single-module/match-series-modal/match-series-modal.component"; +import { + BulkSetReadingProfileModalComponent +} from "../cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component"; export type LibraryActionCallback = (library: Partial) => void; @@ -813,4 +816,56 @@ export class ActionService { }); } + /** + * Sets the reading profile for multiple series + * @param series + * @param callback + */ + setReadingProfileForMultiple(series: Array, callback?: BooleanActionCallback) { + if (this.readingListModalRef != null) { return; } + + this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); + this.readingListModalRef.componentInstance.seriesIds = series.map(s => s.id) + this.readingListModalRef.componentInstance.title = "" + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(true); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(false); + } + }); + } + + /** + * Sets the reading profile for multiple series + * @param library + * @param callback + */ + setReadingProfileForLibrary(library: Library, callback?: BooleanActionCallback) { + if (this.readingListModalRef != null) { return; } + + this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); + this.readingListModalRef.componentInstance.libraryId = library.id; + this.readingListModalRef.componentInstance.title = "" + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(true); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(false); + } + }); + } + } diff --git a/UI/Web/src/app/_services/filter.service.ts b/UI/Web/src/app/_services/filter.service.ts index e76c1926f..2b9681e90 100644 --- a/UI/Web/src/app/_services/filter.service.ts +++ b/UI/Web/src/app/_services/filter.service.ts @@ -1,8 +1,7 @@ -import { Injectable } from '@angular/core'; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {Injectable} from '@angular/core'; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {environment} from "../../environments/environment"; -import { HttpClient } from "@angular/common/http"; -import {JumpKey} from "../_models/jumpbar/jump-key"; +import {HttpClient} from "@angular/common/http"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; @Injectable({ @@ -13,7 +12,7 @@ export class FilterService { baseUrl = environment.apiUrl; constructor(private httpClient: HttpClient) { } - saveFilter(filter: SeriesFilterV2) { + saveFilter(filter: FilterV2) { return this.httpClient.post(this.baseUrl + 'filter/update', filter); } getAllFilters() { @@ -26,5 +25,4 @@ export class FilterService { renameSmartFilter(filter: SmartFilter) { return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {}); } - } diff --git a/UI/Web/src/app/_services/jumpbar.service.ts b/UI/Web/src/app/_services/jumpbar.service.ts index d9919ff57..48ca08705 100644 --- a/UI/Web/src/app/_services/jumpbar.service.ts +++ b/UI/Web/src/app/_services/jumpbar.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@angular/core'; -import { JumpKey } from '../_models/jumpbar/jump-key'; +import {Injectable} from '@angular/core'; +import {JumpKey} from '../_models/jumpbar/jump-key'; const keySize = 25; // Height of the JumpBar button @@ -105,14 +105,18 @@ export class JumpbarService { getJumpKeys(data :Array, keySelector: (data: any) => string) { const keys: {[key: string]: number} = {}; data.forEach(obj => { - let ch = keySelector(obj).charAt(0).toUpperCase(); - if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { - ch = '#'; + try { + let ch = keySelector(obj).charAt(0).toUpperCase(); + if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { + ch = '#'; + } + if (!keys.hasOwnProperty(ch)) { + keys[ch] = 0; + } + keys[ch] += 1; + } catch (e) { + console.error('Failed to calculate jump key for ', obj, e); } - if (!keys.hasOwnProperty(ch)) { - keys[ch] = 0; - } - keys[ch] += 1; }); return Object.keys(keys).map(k => { k = k.toUpperCase(); diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 314e5c37b..fe0702219 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -1,33 +1,54 @@ -import {HttpClient} from '@angular/common/http'; -import {Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {inject, Injectable} from '@angular/core'; import {tap} from 'rxjs/operators'; -import {of} from 'rxjs'; +import {map, of} from 'rxjs'; import {environment} from 'src/environments/environment'; import {Genre} from '../_models/metadata/genre'; import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; import {Language} from '../_models/metadata/language'; import {PublicationStatusDto} from '../_models/metadata/publication-status-dto'; -import {Person, PersonRole} from '../_models/metadata/person'; +import {allPeopleRoles, Person, PersonRole} from '../_models/metadata/person'; import {Tag} from '../_models/tag'; import {FilterComparison} from '../_models/metadata/v2/filter-comparison'; import {FilterField} from '../_models/metadata/v2/filter-field'; -import {SortField} from "../_models/metadata/series-filter"; +import {mangaFormatFilters, SortField} from "../_models/metadata/series-filter"; import {FilterCombination} from "../_models/metadata/v2/filter-combination"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {FilterStatement} from "../_models/metadata/v2/filter-statement"; import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus"; import {LibraryType} from "../_models/library/library"; import {IHasCast} from "../_models/common/i-has-cast"; import {TextResonse} from "../_types/text-response"; import {QueryContext} from "../_models/metadata/v2/query-context"; +import {AgeRatingPipe} from "../_pipes/age-rating.pipe"; +import {MangaFormatPipe} from "../_pipes/manga-format.pipe"; +import {TranslocoService} from "@jsverse/transloco"; +import {LibraryService} from './library.service'; +import {CollectionTagService} from "./collection-tag.service"; +import {PaginatedResult} from "../_models/pagination"; +import {UtilityService} from "../shared/_services/utility.service"; +import {BrowseGenre} from "../_models/metadata/browse/browse-genre"; +import {BrowseTag} from "../_models/metadata/browse/browse-tag"; +import {ValidFilterEntity} from "../metadata-filter/filter-settings"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonRolePipe} from "../_pipes/person-role.pipe"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' }) export class MetadataService { + private readonly translocoService = inject(TranslocoService); + private readonly libraryService = inject(LibraryService); + private readonly collectionTagService = inject(CollectionTagService); + private readonly utilityService = inject(UtilityService); + baseUrl = environment.apiUrl; private validLanguages: Array = []; + private ageRatingPipe = new AgeRatingPipe(); + private mangaFormatPipe = new MangaFormatPipe(this.translocoService); + private personRolePipe = new PersonRolePipe(); constructor(private httpClient: HttpClient) { } @@ -74,6 +95,28 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } + getGenreWithCounts(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'metadata/genres-with-counts', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + + getTagWithCounts(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'metadata/tags-with-counts', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + getAllLanguages(libraries?: Array) { let method = 'metadata/languages' if (libraries != undefined && libraries.length > 0) { @@ -110,19 +153,28 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + 'metadata/people-by-role?role=' + role); } - createDefaultFilterDto(): SeriesFilterV2 { + createDefaultFilterDto(entityType: ValidFilterEntity): FilterV2 { return { - statements: [] as FilterStatement[], + statements: [] as FilterStatement[], combination: FilterCombination.And, limitTo: 0, sortOptions: { isAscending: true, - sortField: SortField.SortName + sortField: (entityType === 'series' ? SortField.SortName : PersonSortField.Name) as TSort } }; } - createDefaultFilterStatement(field: FilterField = FilterField.SeriesName, comparison = FilterComparison.Equal, value = '') { + createDefaultFilterStatement(entityType: ValidFilterEntity) { + switch (entityType) { + case 'series': + return this.createFilterStatement(FilterField.SeriesName); + case 'person': + return this.createFilterStatement(PersonFilterField.Role, FilterComparison.Contains, `${PersonRole.CoverArtist},${PersonRole.Writer}`); + } + } + + createFilterStatement(field: T, comparison = FilterComparison.Equal, value = '') { return { comparison: comparison, field: field, @@ -130,7 +182,7 @@ export class MetadataService { }; } - updateFilter(arr: Array, index: number, filterStmt: FilterStatement) { + updateFilter(arr: Array>, index: number, filterStmt: FilterStatement) { arr[index].comparison = filterStmt.comparison; arr[index].field = filterStmt.field; arr[index].value = filterStmt.value ? filterStmt.value + '' : ''; @@ -140,8 +192,6 @@ export class MetadataService { switch (role) { case PersonRole.Other: break; - case PersonRole.Artist: - break; case PersonRole.CoverArtist: entity.coverArtists = persons; break; @@ -183,4 +233,85 @@ export class MetadataService { break; } } + + /** + * Used to get the underlying Options (for Metadata Filter Dropdowns) + * @param filterField + * @param entityType + */ + getOptionsForFilterField(filterField: T, entityType: ValidFilterEntity) { + + switch (entityType) { + case 'series': + return this.getSeriesOptionsForFilterField(filterField as FilterField); + case 'person': + return this.getPersonOptionsForFilterField(filterField as PersonFilterField); + } + } + + private getPersonOptionsForFilterField(field: PersonFilterField) { + switch (field) { + case PersonFilterField.Role: + return of(allPeopleRoles.map(r => {return {value: r, label: this.personRolePipe.transform(r)}})); + } + return of([]) + } + + private getSeriesOptionsForFilterField(field: FilterField) { + switch (field) { + case FilterField.PublicationStatus: + return this.getAllPublicationStatus().pipe(map(pubs => pubs.map(pub => { + return {value: pub.value, label: pub.title} + }))); + case FilterField.AgeRating: + return this.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => { + return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)} + }))); + case FilterField.Genres: + return this.getAllGenres().pipe(map(genres => genres.map(genre => { + return {value: genre.id, label: genre.title} + }))); + case FilterField.Languages: + return this.getAllLanguages().pipe(map(statuses => statuses.map(status => { + return {value: status.isoCode, label: status.title + ` (${status.isoCode})`} + }))); + case FilterField.Formats: + return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => { + return {value: status.value, label: this.mangaFormatPipe.transform(status.value)} + }))); + case FilterField.Libraries: + return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => { + return {value: lib.id, label: lib.name} + }))); + case FilterField.Tags: + return this.getAllTags().pipe(map(statuses => statuses.map(status => { + return {value: status.id, label: status.title} + }))); + case FilterField.CollectionTags: + return this.collectionTagService.allCollections().pipe(map(statuses => statuses.map(status => { + return {value: status.id, label: status.title} + }))); + case FilterField.Characters: return this.getPersonOptions(PersonRole.Character); + case FilterField.Colorist: return this.getPersonOptions(PersonRole.Colorist); + case FilterField.CoverArtist: return this.getPersonOptions(PersonRole.CoverArtist); + case FilterField.Editor: return this.getPersonOptions(PersonRole.Editor); + case FilterField.Inker: return this.getPersonOptions(PersonRole.Inker); + case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer); + case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller); + case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher); + case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint); + case FilterField.Team: return this.getPersonOptions(PersonRole.Team); + case FilterField.Location: return this.getPersonOptions(PersonRole.Location); + case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator); + case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer); + } + + return of([]); + } + + private getPersonOptions(role: PersonRole) { + return this.getAllPeopleByRole(role).pipe(map(people => people.map(person => { + return {value: person.id, label: person.name} + }))); + } } diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index 0ac58b178..fc9148135 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -6,9 +6,12 @@ import {PaginatedResult} from "../_models/pagination"; import {Series} from "../_models/series"; import {map} from "rxjs/operators"; import {UtilityService} from "../shared/_services/utility.service"; -import {BrowsePerson} from "../_models/person/browse-person"; +import {BrowsePerson} from "../_models/metadata/browse/browse-person"; import {StandaloneChapter} from "../_models/standalone-chapter"; import {TextResonse} from "../_types/text-response"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; +import {PersonFilterField} from "../_models/metadata/v2/person-filter-field"; +import {PersonSortField} from "../_models/metadata/v2/person-sort-field"; @Injectable({ providedIn: 'root' @@ -43,17 +46,28 @@ export class PersonService { return this.httpClient.get>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`); } - getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) { + getAuthorsToBrowse(filter: FilterV2, pageNum?: number, itemsPerPage?: number) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); - return this.httpClient.post>(this.baseUrl + 'person/all', {}, {observe: 'response', params}).pipe( + return this.httpClient.post>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe( map((response: any) => { return this.utilityService.createPaginatedResult(response) as PaginatedResult; }) ); } + // getAuthorsToBrowse(filter: BrowsePersonFilter, pageNum?: number, itemsPerPage?: number) { + // let params = new HttpParams(); + // params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + // + // return this.httpClient.post>(this.baseUrl + `person/all`, filter, {observe: 'response', params}).pipe( + // map((response: any) => { + // return this.utilityService.createPaginatedResult(response) as PaginatedResult; + // }) + // ); + // } + downloadCover(personId: number) { return this.httpClient.post(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 9941cd005..05958ee61 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -16,13 +16,14 @@ import {TextResonse} from '../_types/text-response'; import {AccountService} from './account.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {PersonalToC} from "../_models/readers/personal-toc"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import NoSleep from 'nosleep.js'; import {FullProgress} from "../_models/readers/full-progress"; import {Volume} from "../_models/volume"; import {UtilityService} from "../shared/_services/utility.service"; import {translate} from "@jsverse/transloco"; import {ToastrService} from "ngx-toastr"; +import {FilterField} from "../_models/metadata/v2/filter-field"; export const CHAPTER_ID_DOESNT_EXIST = -1; @@ -107,7 +108,7 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page}); } - getAllBookmarks(filter: SeriesFilterV2 | undefined) { + getAllBookmarks(filter: FilterV2 | undefined) { return this.httpClient.post(this.baseUrl + 'reader/all-bookmarks', filter); } diff --git a/UI/Web/src/app/_services/reading-profile.service.ts b/UI/Web/src/app/_services/reading-profile.service.ts new file mode 100644 index 000000000..e8be8b6ab --- /dev/null +++ b/UI/Web/src/app/_services/reading-profile.service.ts @@ -0,0 +1,70 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {environment} from "../../environments/environment"; +import {ReadingProfile} from "../_models/preferences/reading-profiles"; + +@Injectable({ + providedIn: 'root' +}) +export class ReadingProfileService { + + private readonly httpClient = inject(HttpClient); + baseUrl = environment.apiUrl; + + getForSeries(seriesId: number, skipImplicit: boolean = false) { + return this.httpClient.get(this.baseUrl + `reading-profile/${seriesId}?skipImplicit=${skipImplicit}`); + } + + getForLibrary(libraryId: number) { + return this.httpClient.get(this.baseUrl + `reading-profile/library?libraryId=${libraryId}`); + } + + updateProfile(profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + 'reading-profile', profile); + } + + updateParentProfile(seriesId: number, profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + `reading-profile/update-parent?seriesId=${seriesId}`, profile); + } + + createProfile(profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + 'reading-profile/create', profile); + } + + promoteProfile(profileId: number) { + return this.httpClient.post(this.baseUrl + "reading-profile/promote?profileId=" + profileId, {}); + } + + updateImplicit(profile: ReadingProfile, seriesId: number) { + return this.httpClient.post(this.baseUrl + "reading-profile/series?seriesId="+seriesId, profile); + } + + getAllProfiles() { + return this.httpClient.get(this.baseUrl + 'reading-profile/all'); + } + + delete(id: number) { + return this.httpClient.delete(this.baseUrl + `reading-profile?profileId=${id}`); + } + + addToSeries(id: number, seriesId: number) { + return this.httpClient.post(this.baseUrl + `reading-profile/series/${seriesId}?profileId=${id}`, {}); + } + + clearSeriesProfiles(seriesId: number) { + return this.httpClient.delete(this.baseUrl + `reading-profile/series/${seriesId}`, {}); + } + + addToLibrary(id: number, libraryId: number) { + return this.httpClient.post(this.baseUrl + `reading-profile/library/${libraryId}?profileId=${id}`, {}); + } + + clearLibraryProfiles(libraryId: number) { + return this.httpClient.delete(this.baseUrl + `reading-profile/library/${libraryId}`, {}); + } + + bulkAddToSeries(id: number, seriesIds: number[]) { + return this.httpClient.post(this.baseUrl + `reading-profile/bulk?profileId=${id}`, seriesIds); + } + +} diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index b440b1eb7..39e3b720b 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -1,28 +1,26 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { UtilityService } from '../shared/_services/utility.service'; -import { Chapter } from '../_models/chapter'; -import { PaginatedResult } from '../_models/pagination'; -import { Series } from '../_models/series'; -import { RelatedSeries } from '../_models/series-detail/related-series'; -import { SeriesDetail } from '../_models/series-detail/series-detail'; -import { SeriesGroup } from '../_models/series-group'; -import { SeriesMetadata } from '../_models/metadata/series-metadata'; -import { Volume } from '../_models/volume'; -import { ImageService } from './image.service'; -import { TextResonse } from '../_types/text-response'; -import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2'; -import {UserReview} from "../_single-module/review-card/user-review"; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {Chapter} from '../_models/chapter'; +import {PaginatedResult} from '../_models/pagination'; +import {Series} from '../_models/series'; +import {RelatedSeries} from '../_models/series-detail/related-series'; +import {SeriesDetail} from '../_models/series-detail/series-detail'; +import {SeriesGroup} from '../_models/series-group'; +import {SeriesMetadata} from '../_models/metadata/series-metadata'; +import {Volume} from '../_models/volume'; +import {TextResonse} from '../_types/text-response'; +import {FilterV2} from '../_models/metadata/v2/filter-v2'; import {Rating} from "../_models/rating"; import {Recommendation} from "../_models/series-detail/recommendation"; import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail"; import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter"; import {QueryContext} from "../_models/metadata/v2/query-context"; -import {ExternalSeries} from "../_models/series-detail/external-series"; import {ExternalSeriesMatch} from "../_models/series-detail/external-series-match"; +import {FilterField} from "../_models/metadata/v2/filter-field"; @Injectable({ providedIn: 'root' @@ -33,10 +31,9 @@ export class SeriesService { paginatedResults: PaginatedResult = new PaginatedResult(); paginatedSeriesForTagsResults: PaginatedResult = new PaginatedResult(); - constructor(private httpClient: HttpClient, private imageService: ImageService, - private utilityService: UtilityService) { } + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } - getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2, context: QueryContext = QueryContext.None) { + getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2, context: QueryContext = QueryContext.None) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -48,7 +45,7 @@ export class SeriesService { ); } - getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getSeriesForLibraryV2(pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -100,7 +97,7 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'reader/mark-unread', {seriesId}); } - getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getRecentlyAdded(pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); @@ -116,7 +113,7 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'series/recently-updated-series', {}); } - getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2): Observable> { + getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: FilterV2): Observable> { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -134,7 +131,7 @@ export class SeriesService { })); } - getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: FilterV2) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; @@ -230,5 +227,4 @@ export class SeriesService { updateDontMatch(seriesId: number, dontMatch: boolean) { return this.httpClient.post(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse); } - } diff --git a/UI/Web/src/app/_services/toggle.service.ts b/UI/Web/src/app/_services/toggle.service.ts index 8b335394a..0ad9813e3 100644 --- a/UI/Web/src/app/_services/toggle.service.ts +++ b/UI/Web/src/app/_services/toggle.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; -import { NavigationStart, Router } from '@angular/router'; -import { filter, ReplaySubject, take } from 'rxjs'; +import {Injectable} from '@angular/core'; +import {NavigationStart, Router} from '@angular/router'; +import {filter, ReplaySubject, take} from 'rxjs'; @Injectable({ providedIn: 'root' @@ -29,7 +29,7 @@ export class ToggleService { this.toggleState = !state; this.toggleStateSource.next(this.toggleState); }); - + } set(state: boolean) { diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html index caf8bf683..7573c554a 100644 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html @@ -15,7 +15,7 @@
@for (action of currentItems; track action.title) { - @if (willRenderAction(action)) { + @if (willRenderAction(action, user!)) { + diff --git a/UI/Web/src/app/_single-module/sort-button/sort-button.component.scss b/UI/Web/src/app/_single-module/sort-button/sort-button.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/_single-module/sort-button/sort-button.component.ts b/UI/Web/src/app/_single-module/sort-button/sort-button.component.ts new file mode 100644 index 000000000..230a0ee6f --- /dev/null +++ b/UI/Web/src/app/_single-module/sort-button/sort-button.component.ts @@ -0,0 +1,21 @@ +import {ChangeDetectionStrategy, Component, input, model} from '@angular/core'; +import {TranslocoDirective} from "@jsverse/transloco"; + +@Component({ + selector: 'app-sort-button', + imports: [ + TranslocoDirective + ], + templateUrl: './sort-button.component.html', + styleUrl: './sort-button.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SortButtonComponent { + + disabled = input(false); + isAscending = model(true); + + updateSortOrder() { + this.isAscending.set(!this.isAscending()); + } +} diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html index 60f494f38..2ae9ea45b 100644 --- a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html @@ -3,8 +3,17 @@
-
- +
+ + +
+
+ - - +@if (readingProfile !== null) { + + + +
+
+

+ +

+
+
+ +
+
+
+ + +
-
-
- - +
+ + -
+
-
- - - 1x - - 2.5x +
+ + + {{t('line-spacing-min-label')}} + + {{t('line-spacing-max-label')}} -
+
-
- - +
+ + -
+
-
- +
+ +
-
- + +
+
-
+
+

+ +

+
+
+ +
+ + +
+
+ + {{t('writing-style-tooltip')}} + + -
-

- -

-
-
- -
- - -
-
- - {{t('writing-style-tooltip')}} - - - -
-
- - {{t('tap-to-paginate-tooltip')}} - +
+
+ + {{t('tap-to-paginate-tooltip')}} + -
- - +
+ + +
-
-
- - {{t('immersive-mode-tooltip')}} - +
+ + {{t('immersive-mode-tooltip')}} + -
- - +
+ + +
-
-
- - {{t('fullscreen-tooltip')}} - +
+ + {{t('fullscreen-tooltip')}} + - -
+ +
-
- - - +
+ + + -
-
- - +
+
+ + - - + + - - + + +
-
- + +
-
-
-

- +

+
+
+ +
+ + + +
+
+
+
+
+ +
+ + - -
-
- -
- - - -
-
-
-
-
- - + +
+ + + +} diff --git a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts index e28d44bbf..52c067a16 100644 --- a/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts +++ b/UI/Web/src/app/book-reader/_components/reader-settings/reader-settings.component.ts @@ -1,32 +1,46 @@ -import { DOCUMENT, NgFor, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe } from '@angular/common'; +import {DOCUMENT, NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, DestroyRef, + Component, + DestroyRef, EventEmitter, inject, Inject, + Input, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { take } from 'rxjs'; -import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mode'; -import { BookTheme } from 'src/app/_models/preferences/book-theme'; -import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; -import { WritingStyle } from 'src/app/_models/preferences/writing-style'; -import { ThemeProvider } from 'src/app/_models/preferences/site-theme'; -import { User } from 'src/app/_models/user'; -import { AccountService } from 'src/app/_services/account.service'; -import { ThemeService } from 'src/app/_services/theme.service'; -import { FontFamily, BookService } from '../../_services/book.service'; -import { BookBlackTheme } from '../../_models/book-black-theme'; -import { BookDarkTheme } from '../../_models/book-dark-theme'; -import { BookWhiteTheme } from '../../_models/book-white-theme'; -import { BookPaperTheme } from '../../_models/book-paper-theme'; +import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {skip, take} from 'rxjs'; +import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode'; +import {BookTheme} from 'src/app/_models/preferences/book-theme'; +import {ReadingDirection} from 'src/app/_models/preferences/reading-direction'; +import {WritingStyle} from 'src/app/_models/preferences/writing-style'; +import {ThemeProvider} from 'src/app/_models/preferences/site-theme'; +import {User} from 'src/app/_models/user'; +import {AccountService} from 'src/app/_services/account.service'; +import {ThemeService} from 'src/app/_services/theme.service'; +import {BookService, FontFamily} from '../../_services/book.service'; +import {BookBlackTheme} from '../../_models/book-black-theme'; +import {BookDarkTheme} from '../../_models/book-dark-theme'; +import {BookWhiteTheme} from '../../_models/book-white-theme'; +import {BookPaperTheme} from '../../_models/book-paper-theme'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import { NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import {TranslocoDirective} from "@jsverse/transloco"; +import { + NgbAccordionBody, + NgbAccordionButton, + NgbAccordionCollapse, + NgbAccordionDirective, + NgbAccordionHeader, + NgbAccordionItem, + NgbTooltip +} from '@ng-bootstrap/ng-bootstrap'; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {ReadingProfileService} from "../../../_services/reading-profile.service"; +import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles"; +import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; +import {ToastrService} from "ngx-toastr"; /** * Used for book reader. Do not use for other components @@ -89,9 +103,13 @@ const mobileBreakpointMarginOverride = 700; templateUrl: './reader-settings.component.html', styleUrls: ['./reader-settings.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe, TranslocoDirective] + imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton, + NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle, + TitleCasePipe, TranslocoDirective] }) export class ReaderSettingsComponent implements OnInit { + @Input({required:true}) seriesId!: number; + @Input({required:true}) readingProfile!: ReadingProfile; /** * Outputs when clickToPaginate is changed */ @@ -147,6 +165,11 @@ export class ReaderSettingsComponent implements OnInit { settingsForm: FormGroup = new FormGroup({}); + /** + * The reading profile itself, unless readingProfile is implicit + */ + parentReadingProfile: ReadingProfile | null = null; + /** * System provided themes */ @@ -166,136 +189,169 @@ export class ReaderSettingsComponent implements OnInit { return WritingStyle; } - - constructor(private bookService: BookService, private accountService: AccountService, @Inject(DOCUMENT) private document: Document, private themeService: ThemeService, - private readonly cdRef: ChangeDetectorRef) {} + private readonly cdRef: ChangeDetectorRef, private readingProfileService: ReadingProfileService, + private toastr: ToastrService) {} ngOnInit(): void { + if (this.readingProfile.kind === ReadingProfileKind.Implicit) { + this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => { + this.parentReadingProfile = parent; + this.cdRef.markForCheck(); + }) + } else { + this.parentReadingProfile = this.readingProfile; + this.cdRef.markForCheck(); + } this.fontFamilies = this.bookService.getFontFamilies(); this.fontOptions = this.fontFamilies.map(f => f.title); + + + this.cdRef.markForCheck(); + this.setupSettings(); + + this.setTheme(this.readingProfile.bookReaderThemeName || this.themeService.defaultBookTheme, false); + this.cdRef.markForCheck(); + + // Emit first time so book reader gets the setting + this.readingDirection.emit(this.readingDirectionModel); + this.bookReaderWritingStyle.emit(this.writingStyleModel); + this.clickToPaginateChanged.emit(this.readingProfile.bookReaderTapToPaginate); + this.layoutModeUpdate.emit(this.readingProfile.bookReaderLayoutMode); + this.immersiveMode.emit(this.readingProfile.bookReaderImmersiveMode); + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (user) { this.user = user; - - if (this.user.preferences.bookReaderFontFamily === undefined) { - this.user.preferences.bookReaderFontFamily = 'default'; - } - if (this.user.preferences.bookReaderFontSize === undefined || this.user.preferences.bookReaderFontSize < 50) { - this.user.preferences.bookReaderFontSize = 100; - } - if (this.user.preferences.bookReaderLineSpacing === undefined || this.user.preferences.bookReaderLineSpacing < 100) { - this.user.preferences.bookReaderLineSpacing = 100; - } - if (this.user.preferences.bookReaderMargin === undefined) { - this.user.preferences.bookReaderMargin = 0; - } - if (this.user.preferences.bookReaderReadingDirection === undefined) { - this.user.preferences.bookReaderReadingDirection = ReadingDirection.LeftToRight; - } - if (this.user.preferences.bookReaderWritingStyle === undefined) { - this.user.preferences.bookReaderWritingStyle = WritingStyle.Horizontal; - } - this.readingDirectionModel = this.user.preferences.bookReaderReadingDirection; - this.writingStyleModel = this.user.preferences.bookReaderWritingStyle; - - - - this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); - this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => { - const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family; - if (familyName === 'default') { - this.pageStyles['font-family'] = 'inherit'; - } else { - this.pageStyles['font-family'] = "'" + familyName + "'"; - } - - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, [])); - this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.pageStyles['font-size'] = value + '%'; - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.user.preferences.bookReaderTapToPaginate, [])); - this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.clickToPaginateChanged.emit(value); - }); - - this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.user.preferences.bookReaderLineSpacing, [])); - this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.pageStyles['line-height'] = value + '%'; - this.styleUpdate.emit(this.pageStyles); - }); - - this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, [])); - this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { - this.pageStyles['margin-left'] = value + 'vw'; - this.pageStyles['margin-right'] = value + 'vw'; - this.styleUpdate.emit(this.pageStyles); - }); - - - - this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, [])); - this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((layoutMode: BookPageLayoutMode) => { - this.layoutModeUpdate.emit(layoutMode); - }); - - this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.user.preferences.bookReaderImmersiveMode, [])); - this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((immersiveMode: boolean) => { - if (immersiveMode) { - this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true); - } - this.immersiveMode.emit(immersiveMode); - }); - - - this.setTheme(this.user.preferences.bookReaderThemeName || this.themeService.defaultBookTheme); - this.cdRef.markForCheck(); - - // Emit first time so book reader gets the setting - this.readingDirection.emit(this.readingDirectionModel); - this.bookReaderWritingStyle.emit(this.writingStyleModel); - this.clickToPaginateChanged.emit(this.user.preferences.bookReaderTapToPaginate); - this.layoutModeUpdate.emit(this.user.preferences.bookReaderLayoutMode); - this.immersiveMode.emit(this.user.preferences.bookReaderImmersiveMode); - - this.resetSettings(); - } else { - this.resetSettings(); } - + // User needs to be loaded before we call this + this.resetSettings(); }); } + setupSettings() { + if (!this.readingProfile) return; + + if (this.readingProfile.bookReaderFontFamily === undefined) { + this.readingProfile.bookReaderFontFamily = 'default'; + } + if (this.readingProfile.bookReaderFontSize === undefined || this.readingProfile.bookReaderFontSize < 50) { + this.readingProfile.bookReaderFontSize = 100; + } + if (this.readingProfile.bookReaderLineSpacing === undefined || this.readingProfile.bookReaderLineSpacing < 100) { + this.readingProfile.bookReaderLineSpacing = 100; + } + if (this.readingProfile.bookReaderMargin === undefined) { + this.readingProfile.bookReaderMargin = 0; + } + if (this.readingProfile.bookReaderReadingDirection === undefined) { + this.readingProfile.bookReaderReadingDirection = ReadingDirection.LeftToRight; + } + if (this.readingProfile.bookReaderWritingStyle === undefined) { + this.readingProfile.bookReaderWritingStyle = WritingStyle.Horizontal; + } + this.readingDirectionModel = this.readingProfile.bookReaderReadingDirection; + this.writingStyleModel = this.readingProfile.bookReaderWritingStyle; + + this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.readingProfile.bookReaderFontFamily, [])); + this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => { + const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family; + if (familyName === 'default') { + this.pageStyles['font-family'] = 'inherit'; + } else { + this.pageStyles['font-family'] = "'" + familyName + "'"; + } + + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.readingProfile.bookReaderFontSize, [])); + this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { + this.pageStyles['font-size'] = value + '%'; + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.readingProfile.bookReaderTapToPaginate, [])); + this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { + this.clickToPaginateChanged.emit(value); + }); + + this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.readingProfile.bookReaderLineSpacing, [])); + this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { + this.pageStyles['line-height'] = value + '%'; + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('bookReaderMargin', new FormControl(this.readingProfile.bookReaderMargin, [])); + this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => { + this.pageStyles['margin-left'] = value + 'vw'; + this.pageStyles['margin-right'] = value + 'vw'; + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('layoutMode', new FormControl(this.readingProfile.bookReaderLayoutMode, [])); + this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((layoutMode: BookPageLayoutMode) => { + this.layoutModeUpdate.emit(layoutMode); + }); + + this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.readingProfile.bookReaderImmersiveMode, [])); + this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((immersiveMode: boolean) => { + if (immersiveMode) { + this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true); + } + this.immersiveMode.emit(immersiveMode); + }); + + // Update implicit reading profile while changing settings + this.settingsForm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged(), + skip(1), // Skip the initial creation of the form, we do not want an implicit profile of this snapshot + takeUntilDestroyed(this.destroyRef), + tap(_ => this.updateImplicit()) + ).subscribe(); + } + resetSettings() { + if (!this.readingProfile) return; + if (this.user) { - this.setPageStyles(this.user.preferences.bookReaderFontFamily, this.user.preferences.bookReaderFontSize + '%', this.user.preferences.bookReaderMargin + 'vw', this.user.preferences.bookReaderLineSpacing + '%'); + this.setPageStyles(this.readingProfile.bookReaderFontFamily, this.readingProfile.bookReaderFontSize + '%', this.readingProfile.bookReaderMargin + 'vw', this.readingProfile.bookReaderLineSpacing + '%'); } else { this.setPageStyles(); } - this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily); - this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize); - this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.user.preferences.bookReaderLineSpacing); - this.settingsForm.get('bookReaderMargin')?.setValue(this.user.preferences.bookReaderMargin); - this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.user.preferences.bookReaderReadingDirection); - this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.user.preferences.bookReaderTapToPaginate); - this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.user.preferences.bookReaderLayoutMode); - this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.user.preferences.bookReaderImmersiveMode); - this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.user.preferences.bookReaderWritingStyle); + this.settingsForm.get('bookReaderFontFamily')?.setValue(this.readingProfile.bookReaderFontFamily); + this.settingsForm.get('bookReaderFontSize')?.setValue(this.readingProfile.bookReaderFontSize); + this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.readingProfile.bookReaderLineSpacing); + this.settingsForm.get('bookReaderMargin')?.setValue(this.readingProfile.bookReaderMargin); + this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.readingProfile.bookReaderReadingDirection); + this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.readingProfile.bookReaderTapToPaginate); + this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.readingProfile.bookReaderLayoutMode); + this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.readingProfile.bookReaderImmersiveMode); + this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.readingProfile.bookReaderWritingStyle); + this.cdRef.detectChanges(); this.styleUpdate.emit(this.pageStyles); } + updateImplicit() { + this.readingProfileService.updateImplicit(this.packReadingProfile(), this.seriesId).subscribe({ + next: newProfile => { + this.readingProfile = newProfile; + this.cdRef.markForCheck(); + }, + error: err => { + console.error(err); + } + }) + } + /** * Internal method to be used by resetSettings. Pass items in with quantifiers */ @@ -318,11 +374,15 @@ export class ReaderSettingsComponent implements OnInit { }; } - setTheme(themeName: string) { + setTheme(themeName: string, update: boolean = true) { const theme = this.themes.find(t => t.name === themeName); this.activeTheme = theme; this.cdRef.markForCheck(); this.colorThemeUpdate.emit(theme); + + if (update) { + this.updateImplicit(); + } } toggleReadingDirection() { @@ -334,6 +394,7 @@ export class ReaderSettingsComponent implements OnInit { this.cdRef.markForCheck(); this.readingDirection.emit(this.readingDirectionModel); + this.updateImplicit(); } toggleWritingStyle() { @@ -345,6 +406,7 @@ export class ReaderSettingsComponent implements OnInit { this.cdRef.markForCheck(); this.bookReaderWritingStyle.emit(this.writingStyleModel); + this.updateImplicit(); } toggleFullscreen() { @@ -352,4 +414,53 @@ export class ReaderSettingsComponent implements OnInit { this.cdRef.markForCheck(); this.fullscreen.emit(); } + + // menu only code + updateParentPref() { + if (this.readingProfile.kind !== ReadingProfileKind.Implicit) { + return; + } + + this.readingProfileService.updateParentProfile(this.seriesId, this.packReadingProfile()).subscribe(newProfile => { + this.readingProfile = newProfile; + this.toastr.success(translate('manga-reader.reading-profile-updated')); + this.cdRef.markForCheck(); + }); + } + + createNewProfileFromImplicit() { + if (this.readingProfile.kind !== ReadingProfileKind.Implicit) { + return; + } + + this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => { + this.readingProfile = newProfile; + this.parentReadingProfile = newProfile; // profile is no longer implicit + this.cdRef.markForCheck(); + + this.toastr.success(translate("manga-reader.reading-profile-promoted")); + }); + } + + private packReadingProfile(): ReadingProfile { + const modelSettings = this.settingsForm.getRawValue(); + const data = {...this.readingProfile!}; + data.bookReaderFontFamily = modelSettings.bookReaderFontFamily; + data.bookReaderFontSize = modelSettings.bookReaderFontSize + data.bookReaderLineSpacing = modelSettings.bookReaderLineSpacing; + data.bookReaderMargin = modelSettings.bookReaderMargin; + data.bookReaderTapToPaginate = modelSettings.bookReaderTapToPaginate; + data.bookReaderLayoutMode = modelSettings.layoutMode; + data.bookReaderImmersiveMode = modelSettings.bookReaderImmersiveMode; + + data.bookReaderReadingDirection = this.readingDirectionModel; + data.bookReaderWritingStyle = this.writingStyleModel; + if (this.activeTheme) { + data.bookReaderThemeName = this.activeTheme.name; + } + + return data; + } + + protected readonly ReadingProfileKind = ReadingProfileKind; } diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html index 585f3af42..ead8b3540 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html @@ -17,12 +17,12 @@ } @else { @for (chapterGroup of chapters; track chapterGroup.title + chapterGroup.children.length) {
    -
  • +
  • {{chapterGroup.title}}
  • @for(chapter of chapterGroup.children; track chapter.title + chapter.part) { diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss index e556f0e78..ca8e747f4 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss @@ -3,9 +3,10 @@ &.active { font-weight: bold; + color: var(--primary-color); } } .chapter-title { padding-inline-start: 1rem; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts index cb6417874..ce3a180ed 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts @@ -31,9 +31,8 @@ export class TableOfContentsComponent implements OnChanges { @Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter(); ngOnChanges(changes: SimpleChanges) { - console.log('Current Page: ', this.pageNum, this.currentPageAnchor); + //console.log('Current Page: ', this.pageNum, this.currentPageAnchor); this.cdRef.markForCheck(); - } cleanIdSelector(id: string) { @@ -47,4 +46,30 @@ export class TableOfContentsComponent implements OnChanges { loadChapterPage(pageNum: number, part: string) { this.loadChapter.emit({pageNum, part}); } + + isChapterSelected(chapterGroup: BookChapterItem) { + if (chapterGroup.page === this.pageNum) { + return true; + } + + const idx = this.chapters.indexOf(chapterGroup); + if (idx < 0) { + return false; // should never happen + } + + const nextIdx = idx + 1; + // Last chapter + if (nextIdx >= this.chapters.length) { + return chapterGroup.page < this.pageNum; + } + + // Passed chapter, and next chapter has not been reached + const next = this.chapters[nextIdx]; + return chapterGroup.page < this.pageNum && next.page > this.pageNum; + } + + isAnchorSelected(chapter: BookChapterItem) { + return this.cleanIdSelector(chapter.part) === this.currentPageAnchor + } + } diff --git a/UI/Web/src/app/book-reader/_models/book-paper-theme.ts b/UI/Web/src/app/book-reader/_models/book-paper-theme.ts index 41c7958e1..9b6a5d461 100644 --- a/UI/Web/src/app/book-reader/_models/book-paper-theme.ts +++ b/UI/Web/src/app/book-reader/_models/book-paper-theme.ts @@ -48,6 +48,7 @@ export const BookPaperTheme = ` --btn-disabled-bg-color: #343a40; --btn-disabled-text-color: #efefef; --btn-disabled-border-color: #6c757d; + --btn-outline-primary-text-color: black; /* Inputs */ --input-bg-color: white; @@ -89,6 +90,8 @@ export const BookPaperTheme = ` /* Custom variables */ --theme-bg-color: #fff3c9; + + --bs-secondary-bg: darkgrey; } .reader-container { diff --git a/UI/Web/src/app/book-reader/_models/book-white-theme.ts b/UI/Web/src/app/book-reader/_models/book-white-theme.ts index 1b4bab274..a209b954f 100644 --- a/UI/Web/src/app/book-reader/_models/book-white-theme.ts +++ b/UI/Web/src/app/book-reader/_models/book-white-theme.ts @@ -51,6 +51,7 @@ export const BookWhiteTheme = ` --btn-disabled-bg-color: #343a40; --btn-disabled-text-color: #efefef; --btn-disabled-border-color: #6c757d; + --btn-outline-primary-text-color: black; /* Inputs */ --input-bg-color: white; diff --git a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts index f3b638ce9..3f76c9cf2 100644 --- a/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts +++ b/UI/Web/src/app/bookmark/_components/bookmarks/bookmarks.component.ts @@ -1,9 +1,16 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + inject, + OnInit +} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; import {ToastrService} from 'ngx-toastr'; import {take} from 'rxjs'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; -import {FilterSettings} from 'src/app/metadata-filter/filter-settings'; import {ConfirmService} from 'src/app/shared/confirm.service'; import {DownloadService} from 'src/app/shared/_services/download.service'; import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; @@ -11,7 +18,7 @@ import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {PageBookmark} from 'src/app/_models/readers/page-bookmark'; import {Pagination} from 'src/app/_models/pagination'; import {Series} from 'src/app/_models/series'; -import {FilterEvent} from 'src/app/_models/metadata/series-filter'; +import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {ImageService} from 'src/app/_services/image.service'; import {JumpbarService} from 'src/app/_services/jumpbar.service'; @@ -24,9 +31,14 @@ import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco"; -import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../../../_models/metadata/v2/filter-v2"; import {Title} from "@angular/platform-browser"; import {WikiLink} from "../../../_models/wiki"; +import {FilterField} from "../../../_models/metadata/v2/filter-field"; +import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; +import {MetadataService} from "../../../_services/metadata.service"; @Component({ selector: 'app-bookmarks', @@ -51,6 +63,8 @@ export class BookmarksComponent implements OnInit { private readonly titleService = inject(Title); public readonly bulkSelectionService = inject(BulkSelectionService); public readonly imageService = inject(ImageService); + public readonly metadataService = inject(MetadataService); + public readonly destroyRef = inject(DestroyRef); protected readonly WikiLink = WikiLink; @@ -63,27 +77,34 @@ export class BookmarksComponent implements OnInit { jumpbarKeys: Array = []; pagination: Pagination = new Pagination(); - filter: SeriesFilterV2 | undefined = undefined; - filterSettings: FilterSettings = new FilterSettings(); + filter: FilterV2 | undefined = undefined; + filterSettings: SeriesFilterSettings = new SeriesFilterSettings(); filterOpen: EventEmitter = new EventEmitter(); filterActive: boolean = false; - filterActiveCheck!: SeriesFilterV2; + filterActiveCheck!: FilterV2; trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`; refresh: EventEmitter = new EventEmitter(); constructor() { - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter; - this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); - this.filterActiveCheck!.statements.push(this.filterUtilityService.createSeriesV2DefaultStatement()); + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('series'); + this.filter.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement); + } + + this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series'); + this.filterActiveCheck.statements.push(this.metadataService.createDefaultFilterStatement('series') as FilterStatement); this.filterSettings.presetsV2 = this.filter; this.filterSettings.statementLimit = 1; this.cdRef.markForCheck(); }); + this.titleService.setTitle('Kavita - ' + translate('bookmarks.title')); } @@ -190,7 +211,7 @@ export class BookmarksComponent implements OnInit { this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id)); } - updateFilter(data: FilterEvent) { + updateFilter(data: FilterEvent) { if (data.filterV2 === undefined) return; this.filter = data.filterV2; diff --git a/UI/Web/src/app/browse-people/browse-authors.component.ts b/UI/Web/src/app/browse-people/browse-authors.component.ts deleted file mode 100644 index 9a9114f52..000000000 --- a/UI/Web/src/app/browse-people/browse-authors.component.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - DestroyRef, - EventEmitter, - inject, - OnInit -} from '@angular/core'; -import { - SideNavCompanionBarComponent -} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; -import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component"; -import {DecimalPipe} from "@angular/common"; -import {Series} from "../_models/series"; -import {Pagination} from "../_models/pagination"; -import {JumpKey} from "../_models/jumpbar/jump-key"; -import {ActivatedRoute, Router} from "@angular/router"; -import {Title} from "@angular/platform-browser"; -import {ActionFactoryService} from "../_services/action-factory.service"; -import {ActionService} from "../_services/action.service"; -import {MessageHubService} from "../_services/message-hub.service"; -import {UtilityService} from "../shared/_services/utility.service"; -import {PersonService} from "../_services/person.service"; -import {BrowsePerson} from "../_models/person/browse-person"; -import {JumpbarService} from "../_services/jumpbar.service"; -import {PersonCardComponent} from "../cards/person-card/person-card.component"; -import {ImageService} from "../_services/image.service"; -import {TranslocoDirective} from "@jsverse/transloco"; -import {CompactNumberPipe} from "../_pipes/compact-number.pipe"; - - -@Component({ - selector: 'app-browse-authors', - imports: [ - SideNavCompanionBarComponent, - TranslocoDirective, - CardDetailLayoutComponent, - DecimalPipe, - PersonCardComponent, - CompactNumberPipe, - ], - templateUrl: './browse-authors.component.html', - styleUrl: './browse-authors.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class BrowseAuthorsComponent implements OnInit { - - private readonly destroyRef = inject(DestroyRef); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); - private readonly titleService = inject(Title); - private readonly actionFactoryService = inject(ActionFactoryService); - private readonly actionService = inject(ActionService); - private readonly hubService = inject(MessageHubService); - private readonly utilityService = inject(UtilityService); - private readonly personService = inject(PersonService); - private readonly jumpbarService = inject(JumpbarService); - protected readonly imageService = inject(ImageService); - - - series: Series[] = []; - isLoading = false; - authors: Array = []; - pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0}; - refresh: EventEmitter = new EventEmitter(); - jumpKeys: Array = []; - trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`; - - ngOnInit() { - this.isLoading = true; - this.cdRef.markForCheck(); - this.personService.getAuthorsToBrowse(undefined, undefined).subscribe(d => { - this.authors = d.result; - this.pagination = d.pagination; - this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name); - this.isLoading = false; - this.cdRef.markForCheck(); - }); - } - - goToPerson(person: BrowsePerson) { - this.router.navigate(['person', person.name]); - } - -} diff --git a/UI/Web/src/app/browse/browse-genres/browse-genres.component.html b/UI/Web/src/app/browse/browse-genres/browse-genres.component.html new file mode 100644 index 000000000..5eef2c91f --- /dev/null +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.html @@ -0,0 +1,34 @@ +
    + + +

    + {{t('title')}} +

    +
    {{t('genre-count', {num: pagination.totalItems | number})}}
    + +
    + + + + +
    +
    {{ item.title }}
    +
    + {{t('series-count', {num: item.seriesCount | compactNumber})}} + {{t('issue-count', {num: item.chapterCount | compactNumber})}} +
    +
    + +
    +
    + +
    +
    diff --git a/UI/Web/src/app/browse/browse-genres/browse-genres.component.scss b/UI/Web/src/app/browse/browse-genres/browse-genres.component.scss new file mode 100644 index 000000000..90c313b37 --- /dev/null +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.scss @@ -0,0 +1 @@ +@use '../../../tag-card-common'; diff --git a/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts b/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts new file mode 100644 index 000000000..02c2a8ead --- /dev/null +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts @@ -0,0 +1,68 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core'; +import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component"; +import {DecimalPipe} from "@angular/common"; +import { + SideNavCompanionBarComponent +} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {JumpbarService} from "../../_services/jumpbar.service"; +import {BrowsePerson} from "../../_models/metadata/browse/browse-person"; +import {Pagination} from "../../_models/pagination"; +import {JumpKey} from "../../_models/jumpbar/jump-key"; +import {MetadataService} from "../../_services/metadata.service"; +import {BrowseGenre} from "../../_models/metadata/browse/browse-genre"; +import {FilterField} from "../../_models/metadata/v2/filter-field"; +import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; +import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; +import {Title} from "@angular/platform-browser"; + +@Component({ + selector: 'app-browse-genres', + imports: [ + CardDetailLayoutComponent, + DecimalPipe, + SideNavCompanionBarComponent, + TranslocoDirective, + CompactNumberPipe + ], + templateUrl: './browse-genres.component.html', + styleUrl: './browse-genres.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BrowseGenresComponent implements OnInit { + + protected readonly FilterField = FilterField; + + private readonly cdRef = inject(ChangeDetectorRef); + private readonly metadataService = inject(MetadataService); + private readonly jumpbarService = inject(JumpbarService); + private readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly titleService = inject(Title); + + isLoading = false; + genres: Array = []; + pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0}; + refresh: EventEmitter = new EventEmitter(); + jumpKeys: Array = []; + trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`; + + ngOnInit() { + this.isLoading = true; + this.cdRef.markForCheck(); + + this.titleService.setTitle('Kavita - ' + translate('browse-genres.title')); + + this.metadataService.getGenreWithCounts(undefined, undefined).subscribe(d => { + this.genres = d.result; + this.pagination = d.pagination; + this.jumpKeys = this.jumpbarService.getJumpKeys(this.genres, (d: BrowseGenre) => d.title); + this.isLoading = false; + this.cdRef.markForCheck(); + }); + } + + openFilter(field: FilterField, value: string | number) { + this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe(); + } +} diff --git a/UI/Web/src/app/browse-people/browse-authors.component.html b/UI/Web/src/app/browse/browse-people/browse-people.component.html similarity index 60% rename from UI/Web/src/app/browse-people/browse-authors.component.html rename to UI/Web/src/app/browse/browse-people/browse-people.component.html index 600f7c71c..683a5c97b 100644 --- a/UI/Web/src/app/browse-people/browse-authors.component.html +++ b/UI/Web/src/app/browse/browse-people/browse-people.component.html @@ -1,6 +1,6 @@
    - - + +

    {{t('title')}}

    @@ -16,13 +16,16 @@ [jumpBarKeys]="jumpKeys" [filteringDisabled]="true" [refresh]="refresh" + [filterSettings]="filterSettings" + [filterOpen]="filterOpen" + (applyFilter)="updateFilter($event)" > -
    -
    {{item.seriesCount | compactNumber}} series
    -
    {{item.issueCount | compactNumber}} issues
    +
    +
    {{t('series-count', {num: item.seriesCount | compactNumber})}}
    +
    {{t('issue-count', {num: item.chapterCount | compactNumber})}}
    diff --git a/UI/Web/src/app/browse-people/browse-authors.component.scss b/UI/Web/src/app/browse/browse-people/browse-people.component.scss similarity index 64% rename from UI/Web/src/app/browse-people/browse-authors.component.scss rename to UI/Web/src/app/browse/browse-people/browse-people.component.scss index dc52bc49c..e7dfb8d01 100644 --- a/UI/Web/src/app/browse-people/browse-authors.component.scss +++ b/UI/Web/src/app/browse/browse-people/browse-people.component.scss @@ -1,3 +1,5 @@ +@use '../../../tag-card-common'; + .main-container { margin-top: 10px; padding: 0 0 0 10px; diff --git a/UI/Web/src/app/browse/browse-people/browse-people.component.ts b/UI/Web/src/app/browse/browse-people/browse-people.component.ts new file mode 100644 index 000000000..182222879 --- /dev/null +++ b/UI/Web/src/app/browse/browse-people/browse-people.component.ts @@ -0,0 +1,128 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, EventEmitter, inject} from '@angular/core'; +import { + SideNavCompanionBarComponent +} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; +import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component"; +import {DecimalPipe} from "@angular/common"; +import {Pagination} from "../../_models/pagination"; +import {JumpKey} from "../../_models/jumpbar/jump-key"; +import {ActivatedRoute, Router} from "@angular/router"; +import {PersonService} from "../../_services/person.service"; +import {BrowsePerson} from "../../_models/metadata/browse/browse-person"; +import {JumpbarService} from "../../_services/jumpbar.service"; +import {PersonCardComponent} from "../../cards/person-card/person-card.component"; +import {ImageService} from "../../_services/image.service"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; +import {ReactiveFormsModule} from "@angular/forms"; +import {PersonSortField} from "../../_models/metadata/v2/person-sort-field"; +import {PersonFilterField} from "../../_models/metadata/v2/person-filter-field"; +import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; +import {FilterV2} from "../../_models/metadata/v2/filter-v2"; +import {PersonFilterSettings} from "../../metadata-filter/filter-settings"; +import {FilterEvent} from "../../_models/metadata/series-filter"; +import {PersonRole} from "../../_models/metadata/person"; +import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {MetadataService} from "../../_services/metadata.service"; +import {FilterStatement} from "../../_models/metadata/v2/filter-statement"; + + +@Component({ + selector: 'app-browse-people', + imports: [ + SideNavCompanionBarComponent, + TranslocoDirective, + CardDetailLayoutComponent, + DecimalPipe, + PersonCardComponent, + CompactNumberPipe, + ReactiveFormsModule, + + ], + templateUrl: './browse-people.component.html', + styleUrl: './browse-people.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BrowsePeopleComponent { + protected readonly PersonSortField = PersonSortField; + + private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); + private readonly router = inject(Router); + private readonly personService = inject(PersonService); + private readonly jumpbarService = inject(JumpbarService); + private readonly route = inject(ActivatedRoute); + private readonly filterUtilityService = inject(FilterUtilitiesService); + protected readonly imageService = inject(ImageService); + protected readonly metadataService = inject(MetadataService); + + isLoading = false; + authors: Array = []; + pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0}; + refresh: EventEmitter = new EventEmitter(); + jumpKeys: Array = []; + trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`; + filterSettings: PersonFilterSettings = new PersonFilterSettings(); + filterActive: boolean = false; + filterOpen: EventEmitter = new EventEmitter(); + filter: FilterV2 | undefined = undefined; + filterActiveCheck!: FilterV2; + + + constructor() { + this.isLoading = true; + this.cdRef.markForCheck(); + + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('person'); + this.filter.statements.push(this.metadataService.createDefaultFilterStatement('person') as FilterStatement); + } + + this.filterActiveCheck = this.filterUtilityService.createPersonV2Filter(); + this.filterActiveCheck!.statements.push({value: `${PersonRole.Writer},${PersonRole.CoverArtist}`, field: PersonFilterField.Role, comparison: FilterComparison.Contains}); + this.filterSettings.presetsV2 = this.filter; + + this.cdRef.markForCheck(); + this.loadData(); + }); + } + + + loadData() { + if (!this.filter) { + this.filter = this.metadataService.createDefaultFilterDto('person'); + this.filter.statements.push(this.metadataService.createDefaultFilterStatement('person') as FilterStatement); + this.cdRef.markForCheck(); + } + + this.personService.getAuthorsToBrowse(this.filter!).subscribe(d => { + this.authors = [...d.result]; + this.pagination = d.pagination; + this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name); + this.isLoading = false; + this.cdRef.markForCheck(); + }); + } + + goToPerson(person: BrowsePerson) { + this.router.navigate(['person', person.name]); + } + + updateFilter(data: FilterEvent) { + if (data.filterV2 === undefined) return; + this.filter = data.filterV2; + + if (data.isFirst) { + this.loadData(); + return; + } + + this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((_) => { + this.loadData(); + }); + } +} diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.html b/UI/Web/src/app/browse/browse-tags/browse-tags.component.html new file mode 100644 index 000000000..dcd59bb1f --- /dev/null +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.html @@ -0,0 +1,34 @@ +
    + + +

    + {{t('title')}} +

    +
    {{t('genre-count', {num: pagination.totalItems | number})}}
    + +
    + + + + +
    +
    {{ item.title }}
    +
    + {{t('series-count', {num: item.seriesCount | compactNumber})}} + {{t('issue-count', {num: item.chapterCount | compactNumber})}} +
    +
    + +
    +
    + +
    +
    diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.scss b/UI/Web/src/app/browse/browse-tags/browse-tags.component.scss new file mode 100644 index 000000000..90c313b37 --- /dev/null +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.scss @@ -0,0 +1 @@ +@use '../../../tag-card-common'; diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts b/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts new file mode 100644 index 000000000..92910b0b9 --- /dev/null +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts @@ -0,0 +1,67 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core'; +import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component"; +import {DecimalPipe} from "@angular/common"; +import { + SideNavCompanionBarComponent +} from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {MetadataService} from "../../_services/metadata.service"; +import {JumpbarService} from "../../_services/jumpbar.service"; +import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; +import {BrowseGenre} from "../../_models/metadata/browse/browse-genre"; +import {Pagination} from "../../_models/pagination"; +import {JumpKey} from "../../_models/jumpbar/jump-key"; +import {BrowsePerson} from "../../_models/metadata/browse/browse-person"; +import {FilterField} from "../../_models/metadata/v2/filter-field"; +import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {BrowseTag} from "../../_models/metadata/browse/browse-tag"; +import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; +import {Title} from "@angular/platform-browser"; + +@Component({ + selector: 'app-browse-tags', + imports: [ + CardDetailLayoutComponent, + DecimalPipe, + SideNavCompanionBarComponent, + TranslocoDirective, + CompactNumberPipe + ], + templateUrl: './browse-tags.component.html', + styleUrl: './browse-tags.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BrowseTagsComponent implements OnInit { + protected readonly FilterField = FilterField; + + private readonly cdRef = inject(ChangeDetectorRef); + private readonly metadataService = inject(MetadataService); + private readonly jumpbarService = inject(JumpbarService); + protected readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly titleService = inject(Title); + + isLoading = false; + tags: Array = []; + pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0}; + refresh: EventEmitter = new EventEmitter(); + jumpKeys: Array = []; + trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`; + + ngOnInit() { + this.isLoading = true; + this.cdRef.markForCheck(); + this.titleService.setTitle('Kavita - ' + translate('browse-tags.title')); + + this.metadataService.getTagWithCounts(undefined, undefined).subscribe(d => { + this.tags = d.result; + this.pagination = d.pagination; + this.jumpKeys = this.jumpbarService.getJumpKeys(this.tags, (d: BrowseGenre) => d.title); + this.isLoading = false; + this.cdRef.markForCheck(); + }); + } + + openFilter(field: FilterField, value: string | number) { + this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe(); + } +} diff --git a/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.html b/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.html new file mode 100644 index 000000000..d9dbb0715 --- /dev/null +++ b/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.html @@ -0,0 +1,56 @@ + + + +
    + + + +
    + + + + + +
    diff --git a/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.scss b/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.scss new file mode 100644 index 000000000..0974b1867 --- /dev/null +++ b/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.scss @@ -0,0 +1,14 @@ +.clickable:hover, .clickable:focus { + background-color: var(--list-group-hover-bg-color, --primary-color); +} + +.pill { + font-size: .8rem; + background-color: var(--card-bg-color); + border-radius: 0.375rem; + color: var(--badge-text-color); + + &.active { + background-color : var(--primary-color); + } +} diff --git a/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.ts b/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.ts new file mode 100644 index 000000000..484fe7e75 --- /dev/null +++ b/UI/Web/src/app/cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component.ts @@ -0,0 +1,120 @@ +import {AfterViewInit, ChangeDetectorRef, Component, ElementRef, inject, Input, OnInit, ViewChild} from '@angular/core'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {ToastrService} from "ngx-toastr"; +import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {ReadingProfileService} from "../../../_services/reading-profile.service"; +import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles"; +import {FilterPipe} from "../../../_pipes/filter.pipe"; +import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe"; + +@Component({ + selector: 'app-bulk-set-reading-profile-modal', + imports: [ + ReactiveFormsModule, + FilterPipe, + TranslocoDirective, + SentenceCasePipe + ], + templateUrl: './bulk-set-reading-profile-modal.component.html', + styleUrl: './bulk-set-reading-profile-modal.component.scss' +}) +export class BulkSetReadingProfileModalComponent implements OnInit, AfterViewInit { + private readonly modal = inject(NgbActiveModal); + private readonly readingProfileService = inject(ReadingProfileService); + private readonly toastr = inject(ToastrService); + private readonly cdRef = inject(ChangeDetectorRef); + protected readonly MaxItems = 8; + + /** + * Modal Header - since this code is used for multiple flows + */ + @Input({required: true}) title!: string; + /** + * Series Ids to add to Reading Profile + */ + @Input() seriesIds: Array = []; + @Input() libraryId: number | undefined; + @ViewChild('title') inputElem!: ElementRef; + + currentProfile: ReadingProfile | null = null; + profiles: Array = []; + isLoading: boolean = false; + profileForm: FormGroup = new FormGroup({ + filterQuery: new FormControl('', []), // Used for inline filtering when too many RPs + }); + + ngOnInit(): void { + + this.profileForm.addControl('title', new FormControl(this.title, [])); + + this.isLoading = true; + this.cdRef.markForCheck(); + + if (this.libraryId !== undefined) { + this.readingProfileService.getForLibrary(this.libraryId).subscribe(profile => { + this.currentProfile = profile; + this.cdRef.markForCheck(); + }); + } else if (this.seriesIds.length === 1) { + this.readingProfileService.getForSeries(this.seriesIds[0], true).subscribe(profile => { + this.currentProfile = profile; + this.cdRef.markForCheck(); + }); + } + + + this.readingProfileService.getAllProfiles().subscribe(profiles => { + this.profiles = profiles; + this.isLoading = false; + this.cdRef.markForCheck(); + }); + } + + ngAfterViewInit() { + // Shift focus to input + if (this.inputElem) { + this.inputElem.nativeElement.select(); + this.cdRef.markForCheck(); + } + } + + close() { + this.modal.close(); + } + + addToProfile(profile: ReadingProfile) { + if (this.seriesIds.length == 1) { + this.readingProfileService.addToSeries(profile.id, this.seriesIds[0]).subscribe(() => { + this.toastr.success(translate('toasts.series-bound-to-reading-profile', {name: profile.name})); + this.modal.close(); + }); + return; + } + + if (this.seriesIds.length > 1) { + this.readingProfileService.bulkAddToSeries(profile.id, this.seriesIds).subscribe(() => { + this.toastr.success(translate('toasts.series-bound-to-reading-profile', {name: profile.name})); + this.modal.close(); + }); + return; + } + + if (this.libraryId) { + this.readingProfileService.addToLibrary(profile.id, this.libraryId).subscribe(() => { + this.toastr.success(translate('toasts.library-bound-to-reading-profile', {name: profile.name})); + this.modal.close(); + }); + } + } + + filterList = (listItem: ReadingProfile) => { + return listItem.name.toLowerCase().indexOf((this.profileForm.value.filterQuery || '').toLowerCase()) >= 0; + } + + clear() { + this.profileForm.get('filterQuery')?.setValue(''); + } + + protected readonly ReadingProfileKind = ReadingProfileKind; +} diff --git a/UI/Web/src/app/cards/bulk-selection.service.ts b/UI/Web/src/app/cards/bulk-selection.service.ts index ff80a0288..2fa84b50d 100644 --- a/UI/Web/src/app/cards/bulk-selection.service.ts +++ b/UI/Web/src/app/cards/bulk-selection.service.ts @@ -144,7 +144,7 @@ export class BulkSelectionService { */ getActions(callback: (action: ActionItem, data: any) => void) { const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection, - Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList]; + Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList, Action.SetReadingProfile]; if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) { return this.applyFilterToList(this.actionFactory.getSeriesActions(callback), allowedActions); diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index 1981efdab..2bf0a2608 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -1,18 +1,18 @@ - @if (header.length > 0) { + @if (header().length > 0) {

    - @if (actions.length > 0) { + @if (actions().length > 0) { -   +   } - {{header}}  + {{header()}}  @if (pagination) { {{pagination.totalItems}} @@ -24,7 +24,10 @@

    } - + @if (filterSettings) { + + } +
    @@ -34,13 +37,14 @@
    -
    - -
    + @for (item of scroll.viewPortItems; track trackByIdentity(i, item); let i = $index) { +
    + +
    + }
    @@ -54,9 +58,11 @@
    +
    +
    diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index ddc9b5564..183081409 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, ContentChild, DestroyRef, ElementRef, @@ -10,20 +11,22 @@ import { HostListener, inject, Inject, + input, Input, OnChanges, OnInit, Output, + signal, + Signal, SimpleChange, SimpleChanges, TemplateRef, TrackByFunction, - ViewChild + ViewChild, + WritableSignal } from '@angular/core'; import {NavigationStart, Router} from '@angular/router'; import {VirtualScrollerComponent, VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; -import {FilterSettings} from 'src/app/metadata-filter/filter-settings'; -import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {Library} from 'src/app/_models/library/library'; @@ -35,43 +38,59 @@ import {LoadingComponent} from "../../shared/loading/loading.component"; import {MetadataFilterComponent} from "../../metadata-filter/metadata-filter.component"; import {TranslocoDirective} from "@jsverse/transloco"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; -import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2"; import {filter, map} from "rxjs/operators"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {tap} from "rxjs"; +import {FilterV2} from "../../_models/metadata/v2/filter-v2"; +import {FilterSettingsBase, ValidFilterEntity} from "../../metadata-filter/filter-settings"; const ANIMATION_TIME_MS = 0; +/** + * Provides a virtualized card layout, jump bar, and metadata filter bar. + * + * How to use: + * - For filtering: + * - pass a filterSettings which will bootstrap the filtering bar + * - pass a jumpbar method binding to calc the count for the entity (not implemented yet) + * - For card layout + * - Pass an identity function for trackby + * - Pass a pagination object for the total count + * - Pass the items + * - + */ @Component({ selector: 'app-card-detail-layout', imports: [LoadingComponent, VirtualScrollerModule, CardActionablesComponent, MetadataFilterComponent, TranslocoDirective, NgTemplateOutlet, NgClass, NgForOf], templateUrl: './card-detail-layout.component.html', styleUrls: ['./card-detail-layout.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true }) -export class CardDetailLayoutComponent implements OnInit, OnChanges { +export class CardDetailLayoutComponent implements OnInit, OnChanges { - private readonly filterUtilityService = inject(FilterUtilitiesService); protected readonly utilityService = inject(UtilityService); private readonly cdRef = inject(ChangeDetectorRef); private readonly jumpbarService = inject(JumpbarService); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); - protected readonly Breakpoint = Breakpoint; - @Input() header: string = ''; + + header: Signal = input(''); @Input() isLoading: boolean = false; - @Input() items: any[] = []; @Input() pagination!: Pagination; + @Input() items: any[] = []; + + /** * Parent scroll for virtualize pagination */ @Input() parentScroll!: Element | Window; - // Filter Code + // We need to pass filterOpen from the grandfather to the metadata filter due to the filter button being in a separate component @Input() filterOpen!: EventEmitter; /** * Should filtering be shown on the page @@ -80,15 +99,20 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { /** * Any actions to exist on the header for the parent collection (library, collection) */ - @Input() actions: ActionItem[] = []; + actions: Signal[]> = input([]); /** * A trackBy to help with rendering. This is required as without it there are issues when scrolling */ @Input({required: true}) trackByIdentity!: TrackByFunction; - @Input() filterSettings!: FilterSettings; + @Input() filterSettings: FilterSettingsBase | undefined = undefined; + entityType = input(); @Input() refresh!: EventEmitter; + /** + * Will force the jumpbar to be disabled - in cases where you're not using a traditional filter config + */ + customSort = input(false); @Input() jumpBarKeys: Array = []; // This is approx 784 pixels tall, original keys jumpBarKeysToRender: Array = []; // What is rendered on screen @@ -101,13 +125,21 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { @ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent; - filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter(); libraries: Array> = []; updateApplied: number = 0; bufferAmount: number = 1; + filterSignal: WritableSignal | undefined> = signal(undefined); + hasCustomSort = computed(() => { + if (this.customSort()) return true; + if (this.filteringDisabled) return false; + + const filter = this.filterSignal(); + return filter?.sortOptions?.sortField != SortField.SortName || !filter?.sortOptions.isAscending; + }); + constructor(@Inject(DOCUMENT) private document: Document) {} @@ -122,16 +154,12 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { ngOnInit(): void { if (this.trackByIdentity === undefined) { - this.trackByIdentity = (_: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`; - } - - if (this.filterSettings === undefined) { - this.filterSettings = new FilterSettings(); - this.cdRef.markForCheck(); + this.trackByIdentity = (_: number, item: any) => `${this.header()}_${this.updateApplied}_${item?.id}`; } if (this.pagination === undefined) { - this.pagination = {currentPage: 1, itemsPerPage: this.items.length, totalItems: this.items.length, totalPages: 1}; + const items = this.items; + this.pagination = {currentPage: 1, itemsPerPage: items.length, totalItems: items.length, totalPages: 1}; this.cdRef.markForCheck(); } @@ -170,24 +198,16 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { } } - hasCustomSort() { - if (this.filteringDisabled) return false; - const hasCustomSort = this.filter?.sortOptions?.sortField != SortField.SortName || !this.filter?.sortOptions.isAscending; - //const hasNonDefaultSortField = this.filterSettings?.presetsV2?.sortOptions?.sortField != SortField.SortName; - - return hasCustomSort; - } - performAction(action: ActionItem) { if (typeof action.callback === 'function') { action.callback(action, undefined); } } - applyMetadataFilter(event: FilterEvent) { - this.applyFilter.emit(event); + applyMetadataFilter(event: FilterEvent) { + this.applyFilter.emit(event as FilterEvent); this.updateApplied++; - this.filter = event.filterV2; + this.filterSignal.set(event.filterV2); this.cdRef.markForCheck(); } @@ -208,4 +228,6 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges { tryToSaveJumpKey() { this.jumpbarService.saveResumePosition(this.router.url, this.virtualScroller.viewPortInfo.startIndex); } + + protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index dc89c563c..c0a4c0890 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -94,7 +94,7 @@ @if (actions && actions.length > 0) { - + }
    diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 6bdbcaf18..c01fbca98 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -26,7 +26,7 @@ import {Series} from 'src/app/_models/series'; import {User} from 'src/app/_models/user'; import {Volume} from 'src/app/_models/volume'; import {AccountService} from 'src/app/_services/account.service'; -import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; +import {Action, ActionableEntity, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {ImageService} from 'src/app/_services/image.service'; import {LibraryService} from 'src/app/_services/library.service'; import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service'; @@ -46,7 +46,7 @@ import {NextExpectedChapter} from "../../_models/series-detail/next-expected-cha import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component"; import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; -import {BrowsePerson} from "../../_models/person/browse-person"; +import {BrowsePerson} from "../../_models/metadata/browse/browse-person"; import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson; @@ -118,6 +118,10 @@ export class CardItemComponent implements OnInit { * This is the entity we are representing. It will be returned if an action is executed. */ @Input({required: true}) entity!: CardEntity; + /** + * An optional entity to be used in the action callback + */ + @Input() actionEntity: ActionableEntity | null = null; /** * If the entity is selected or not. */ diff --git a/UI/Web/src/app/cards/person-card/person-card.component.ts b/UI/Web/src/app/cards/person-card/person-card.component.ts index 4d50c9926..2e06ed1ad 100644 --- a/UI/Web/src/app/cards/person-card/person-card.component.ts +++ b/UI/Web/src/app/cards/person-card/person-card.component.ts @@ -19,7 +19,7 @@ import {ScrollService} from "../../_services/scroll.service"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {NgTemplateOutlet} from "@angular/common"; -import {BrowsePerson} from "../../_models/person/browse-person"; +import {BrowsePerson} from "../../_models/metadata/browse/browse-person"; import {Person} from "../../_models/metadata/person"; import {FormsModule} from "@angular/forms"; import {ImageComponent} from "../../shared/image/image.component"; diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index c6cfa825f..052f71107 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -24,7 +24,7 @@ import {RelationKind} from 'src/app/_models/series-detail/relation-kind'; import {DecimalPipe} from "@angular/common"; import {RelationshipPipe} from "../../_pipes/relationship.pipe"; import {Device} from "../../_models/device/device"; -import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco"; import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-drawer/series-preview-drawer.component"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; @@ -41,6 +41,7 @@ import {ScrollService} from "../../_services/scroll.service"; import {ReaderService} from "../../_services/reader.service"; import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; import {DefaultModalOptions} from "../../_models/default-modal-options"; +import {ReadingProfileService} from "../../_services/reading-profile.service"; function deepClone(obj: any): any { if (obj === null || typeof obj !== 'object') { @@ -92,6 +93,8 @@ export class SeriesCardComponent implements OnInit, OnChanges { private readonly downloadService = inject(DownloadService); private readonly scrollService = inject(ScrollService); private readonly readerService = inject(ReaderService); + private readonly readingProfilesService = inject(ReadingProfileService); + private readonly translocoService = inject(TranslocoService); @Input({required: true}) series!: Series; @Input() libraryId = 0; @@ -276,6 +279,14 @@ export class SeriesCardComponent implements OnInit, OnChanges { case Action.Download: this.downloadService.download('series', this.series); break; + case Action.SetReadingProfile: + this.actionService.setReadingProfileForMultiple([series]); + break; + case Action.ClearReadingProfile: + this.readingProfilesService.clearSeriesProfiles(series.id).subscribe(() => { + this.toastr.success(this.translocoService.translate('actionable.cleared-profile')); + }); + break; default: break; } diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index 09923b239..599d1c156 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -14,7 +14,7 @@ [trackByIdentity]="trackByIdentity" > - { if (!user) return; - this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)) + this.collectionTagActions = this.actionFactoryService.getCollectionTagActions( + this.handleCollectionActionCallback.bind(this), this.shouldRenderCollection.bind(this)) .filter(action => this.collectionService.actionListFilter(action, user)); this.cdRef.markForCheck(); }); } + shouldRenderCollection(action: ActionItem, entity: UserCollection, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } + loadCollection(item: UserCollection) { this.router.navigate(['collections', item.id]); } diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html index 927316f99..1fad4b6e8 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html @@ -11,7 +11,7 @@ } -
    {{t('item-count', {num: series.length})}}
    +
    {{t('item-count', {num: series.length})}}
    } diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts index ceb539718..9b3f55240 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts @@ -19,7 +19,6 @@ import {ToastrService} from 'ngx-toastr'; import {debounceTime, take} from 'rxjs/operators'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import {EditCollectionTagsComponent} from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component'; -import {FilterSettings} from 'src/app/metadata-filter/filter-settings'; import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service'; import {UserCollection} from 'src/app/_models/collection-tag'; @@ -27,7 +26,7 @@ import {SeriesAddedToCollectionEvent} from 'src/app/_models/events/series-added- import {JumpKey} from 'src/app/_models/jumpbar/jump-key'; import {Pagination} from 'src/app/_models/pagination'; import {Series} from 'src/app/_models/series'; -import {FilterEvent} from 'src/app/_models/metadata/series-filter'; +import {FilterEvent, SortField} from 'src/app/_models/metadata/series-filter'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {ActionService} from 'src/app/_services/action.service'; import {CollectionTagService} from 'src/app/_services/collection-tag.service'; @@ -49,8 +48,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {FilterField} from "../../../_models/metadata/v2/filter-field"; -import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; -import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../../../_models/metadata/v2/filter-v2"; import {AccountService} from "../../../_services/account.service"; import {User} from "../../../_models/user"; import {ScrobbleProvider} from "../../../_services/scrobbling.service"; @@ -62,6 +60,10 @@ import { import {DefaultModalOptions} from "../../../_models/default-modal-options"; import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe"; import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component"; +import {FilterStatement} from "../../../_models/metadata/v2/filter-statement"; +import {SeriesFilterSettings} from "../../../metadata-filter/filter-settings"; +import {MetadataService} from "../../../_services/metadata.service"; +import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison"; @Component({ selector: 'app-collection-detail', @@ -95,6 +97,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { protected readonly utilityService = inject(UtilityService); private readonly cdRef = inject(ChangeDetectorRef); private readonly scrollService = inject(ScrollService); + private readonly metadataService = inject(MetadataService); protected readonly ScrobbleProvider = ScrobbleProvider; protected readonly Breakpoint = Breakpoint; @@ -109,13 +112,13 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { series: Array = []; pagination: Pagination = new Pagination(); collectionTagActions: ActionItem[] = []; - filter: SeriesFilterV2 | undefined = undefined; - filterSettings: FilterSettings = new FilterSettings(); + filter: FilterV2 | undefined = undefined; + filterSettings: SeriesFilterSettings = new SeriesFilterSettings(); summary: string = ''; user!: User; actionInProgress: boolean = false; - filterActiveCheck!: SeriesFilterV2; + filterActiveCheck!: FilterV2; filterActive: boolean = false; jumpbarKeys: Array = []; @@ -188,18 +191,26 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { } const tagId = parseInt(routeId, 10); - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter; + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; - if (this.filter.statements.filter(stmt => stmt.field === FilterField.CollectionTags).length === 0) { - this.filter!.statements.push({field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal}); + const defaultStmt = {field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal}; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('series'); + this.filter.statements.push(defaultStmt); } - this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); - this.filterActiveCheck!.statements.push({field: FilterField.CollectionTags, value: tagId + '', comparison: FilterComparison.Equal}); + + if (this.filter.statements.filter((stmt: FilterStatement) => stmt.field === FilterField.CollectionTags).length === 0) { + this.filter!.statements.push(defaultStmt); + } + + this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series'); + this.filterActiveCheck!.statements.push(defaultStmt); this.filterSettings.presetsV2 = this.filter; - this.cdRef.markForCheck(); this.updateTag(tagId); + this.cdRef.markForCheck(); }); } @@ -207,7 +218,8 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { if (!user) return; this.user = user; - this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)) + this.collectionTagActions = this.actionFactoryService.getCollectionTagActions( + this.handleCollectionActionCallback.bind(this), this.shouldRenderCollection.bind(this)) .filter(action => this.collectionService.actionListFilter(action, user)); this.cdRef.markForCheck(); }); @@ -225,6 +237,17 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { }); } + shouldRenderCollection(action: ActionItem, entity: UserCollection, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } + ngAfterContentChecked(): void { this.scrollService.setScrollContainer(this.scrollingBlock); } @@ -259,7 +282,7 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { }); } - updateFilter(data: FilterEvent) { + updateFilter(data: FilterEvent) { if (data.filterV2 === undefined) return; this.filter = data.filterV2; diff --git a/UI/Web/src/app/dashboard/_components/dashboard.component.ts b/UI/Web/src/app/dashboard/_components/dashboard.component.ts index 73fd10e89..12f763313 100644 --- a/UI/Web/src/app/dashboard/_components/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/_components/dashboard.component.ts @@ -219,7 +219,7 @@ export class DashboardComponent implements OnInit { const params: any = {}; params['page'] = 1; params['title'] = translate('dashboard.recently-updated-title'); - const filter = this.filterUtilityService.createSeriesV2Filter(); + const filter = this.metadataService.createDefaultFilterDto('series'); if (filter.sortOptions) { filter.sortOptions.sortField = SortField.LastChapterAdded; filter.sortOptions.isAscending = false; @@ -230,7 +230,7 @@ export class DashboardComponent implements OnInit { params['page'] = 1; params['title'] = translate('dashboard.on-deck-title'); - const filter = this.filterUtilityService.createSeriesV2Filter(); + const filter = this.metadataService.createDefaultFilterDto('series'); filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'}); filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.NotEqual, value: '100'}); if (filter.sortOptions) { @@ -242,7 +242,7 @@ export class DashboardComponent implements OnInit { const params: any = {}; params['page'] = 1; params['title'] = translate('dashboard.recently-added-title'); - const filter = this.filterUtilityService.createSeriesV2Filter(); + const filter = this.metadataService.createDefaultFilterDto('series'); if (filter.sortOptions) { filter.sortOptions.sortField = SortField.Created; filter.sortOptions.isAscending = false; @@ -252,7 +252,7 @@ export class DashboardComponent implements OnInit { const params: any = {}; params['page'] = 1; params['title'] = translate('dashboard.more-in-genre-title', {genre: this.genre?.title}); - const filter = this.filterUtilityService.createSeriesV2Filter(); + const filter = this.metadataService.createDefaultFilterDto('series'); filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains}); this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe(); } diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index 8b816fc1c..62a60fa7b 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -17,7 +17,7 @@ import {SeriesAddedEvent} from '../_models/events/series-added-event'; import {Library} from '../_models/library/library'; import {Pagination} from '../_models/pagination'; import {Series} from '../_models/series'; -import {FilterEvent} from '../_models/metadata/series-filter'; +import {FilterEvent, SortField} from '../_models/metadata/series-filter'; import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service'; import {ActionService} from '../_services/action.service'; import {LibraryService} from '../_services/library.service'; @@ -25,7 +25,6 @@ import {EVENTS, MessageHubService} from '../_services/message-hub.service'; import {SeriesService} from '../_services/series.service'; import {NavService} from '../_services/nav.service'; import {FilterUtilitiesService} from '../shared/_services/filter-utilities.service'; -import {FilterSettings} from '../metadata-filter/filter-settings'; import {JumpKey} from '../_models/jumpbar/jump-key'; import {SeriesRemovedEvent} from '../_models/events/series-removed-event'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -37,12 +36,14 @@ import { SideNavCompanionBarComponent } from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {TranslocoDirective} from "@jsverse/transloco"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterField} from "../_models/metadata/v2/filter-field"; import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component"; import {LoadingComponent} from "../shared/loading/loading.component"; import {debounceTime, ReplaySubject, tap} from "rxjs"; +import {SeriesFilterSettings} from "../metadata-filter/filter-settings"; +import {MetadataService} from "../_services/metadata.service"; @Component({ selector: 'app-library-detail', @@ -68,6 +69,7 @@ export class LibraryDetailComponent implements OnInit { private readonly filterUtilityService = inject(FilterUtilitiesService); public readonly navService = inject(NavService); public readonly bulkSelectionService = inject(BulkSelectionService); + public readonly metadataService = inject(MetadataService); libraryId!: number; libraryName = ''; @@ -75,11 +77,11 @@ export class LibraryDetailComponent implements OnInit { loadingSeries = false; pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0}; actions: ActionItem[] = []; - filter: SeriesFilterV2 | undefined = undefined; - filterSettings: FilterSettings = new FilterSettings(); + filter: FilterV2 | undefined = undefined; + filterSettings: SeriesFilterSettings = new SeriesFilterSettings(); filterOpen: EventEmitter = new EventEmitter(); filterActive: boolean = false; - filterActiveCheck!: SeriesFilterV2; + filterActiveCheck!: FilterV2; refresh: EventEmitter = new EventEmitter(); jumpKeys: Array = []; bulkLoader: boolean = false; @@ -149,6 +151,14 @@ export class LibraryDetailComponent implements OnInit { this.loadPage(); }); break; + case Action.SetReadingProfile: + this.actionService.setReadingProfileForMultiple(selectedSeries, (success) => { + this.bulkLoader = false; + this.cdRef.markForCheck(); + if (!success) return; + this.bulkSelectionService.deselectAll(); + this.loadPage(); + }) } } @@ -176,16 +186,19 @@ export class LibraryDetailComponent implements OnInit { this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); - this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => { - this.filter = filter; + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.filter = data['filter'] as FilterV2; - if (this.filter.statements.filter(stmt => stmt.field === FilterField.Libraries).length === 0) { - this.filter!.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal}); + const defaultStmt = {field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal}; + + if (this.filter == null) { + this.filter = this.metadataService.createDefaultFilterDto('series'); + this.filter.statements.push(defaultStmt); } - this.filterActiveCheck = this.filterUtilityService.createSeriesV2Filter(); - this.filterActiveCheck.statements.push({field: FilterField.Libraries, value: this.libraryId + '', comparison: FilterComparison.Equal}); + this.filterActiveCheck = this.metadataService.createDefaultFilterDto('series'); + this.filterActiveCheck!.statements.push(defaultStmt); this.filterSettings.presetsV2 = this.filter; this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe(); @@ -303,7 +316,7 @@ export class LibraryDetailComponent implements OnInit { } } - updateFilter(data: FilterEvent) { + updateFilter(data: FilterEvent) { if (data.filterV2 === undefined) return; this.filter = data.filterV2; diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html index 09e84ba6f..98dc26ba4 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.html @@ -33,8 +33,9 @@
    @for(item of webtoonImages | async; let index = $index; track item.src) { - image string; @Input({required: true}) readerSettings$!: Observable; + @Input({required: true}) readingProfile!: ReadingProfile; @Output() pageNumberChange: EventEmitter = new EventEmitter(); @Output() loadNextChapter: EventEmitter = new EventEmitter(); @Output() loadPrevChapter: EventEmitter = new EventEmitter(); @@ -174,13 +179,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, */ debugLogFilter: Array = ['[PREFETCH]', '[Intersection]', '[Visibility]', '[Image Load]']; - /** - * Width override for manual width control - * 2 observables needed to avoid flickering, probably due to data races, when changing the width - * this allows to precisely define execution order - */ - widthOverride$ : Observable = new Observable(); - widthSliderValue$ : Observable = new Observable(); + readerSettings!: Signal; + widthOverride!: Signal; get minPageLoaded() { return Math.min(...Object.values(this.imagesLoaded)); @@ -240,30 +240,37 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, takeUntilDestroyed(this.destroyRef) ); + // We need the injector as toSignal is only allowed in injection context + // https://angular.dev/guide/signals#injection-context + this.readerSettings = toSignal(this.readerSettings$, {injector: this.injector, requireSync: true}); - this.widthSliderValue$ = this.readerSettings$.pipe( - map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'), - takeUntilDestroyed(this.destroyRef) - ); + // Automatically updates when the breakpoint changes, or when reader settings changes + this.widthOverride = computed(() => { + const breakpoint = this.utilityService.activeUserBreakpoint(); + const value = this.readerSettings().widthSlider; - this.widthOverride$ = this.widthSliderValue$; + if (breakpoint <= this.readingProfile.disableWidthOverride) { + return ''; + } + return (parseInt(value) <= 0) ? '' : value + '%'; + }); //perform jump so the page stays in view - this.widthSliderValue$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + effect(() => { + const width = this.widthOverride(); // needs to be at the top for effect to work this.currentPageElem = this.document.querySelector('img#page-' + this.pageNum); if(!this.currentPageElem) return; let images = Array.from(document.querySelectorAll('img[id^="page-"]')) as HTMLImageElement[]; images.forEach((img) => { - this.renderer.setStyle(img, "width", val); + this.renderer.setStyle(img, "width", width); }); - this.widthOverride$ = this.widthSliderValue$; this.prevScrollPosition = this.currentPageElem.getBoundingClientRect().top; this.currentPageElem.scrollIntoView(); this.cdRef.markForCheck(); - }); + }, {injector: this.injector}); if (this.goToPage) { this.goToPage.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(page => { diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html index 195b66b2b..89041956c 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.html @@ -53,7 +53,6 @@
    - @if (readerMode !== ReaderMode.Webtoon) {
    + [showClickOverlay$]="showClickOverlay$" + [readingProfile]="readingProfile"> + [readerSettings$]="readerSettings$" + [readingProfile]="readingProfile">
    } @@ -311,7 +312,17 @@
    - + +
    diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 595ae6079..9483afdb3 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -32,7 +32,7 @@ import { import {ChangeContext, LabelType, NgxSliderModule, Options} from '@angular-slider/ngx-slider'; import {animate, state, style, transition, trigger} from '@angular/animations'; import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; -import {NgbModal} from '@ng-bootstrap/ng-bootstrap'; +import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; import {ShortcutsModalComponent} from 'src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component'; import {Stack} from 'src/app/shared/data-structures/stack'; @@ -40,7 +40,6 @@ import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/ut import {LibraryType} from 'src/app/_models/library/library'; import {MangaFormat} from 'src/app/_models/manga-format'; import {PageSplitOption} from 'src/app/_models/preferences/page-split-option'; -import {layoutModes, pageSplitOptions} from 'src/app/_models/preferences/preferences'; import {ReaderMode} from 'src/app/_models/preferences/reader-mode'; import {ReadingDirection} from 'src/app/_models/preferences/reading-direction'; import {ScalingOption} from 'src/app/_models/preferences/scaling-option'; @@ -70,6 +69,14 @@ import {LoadingComponent} from '../../../shared/loading/loading.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {shareReplay} from "rxjs/operators"; import {DblClickDirective} from "../../../_directives/dbl-click.directive"; +import { + layoutModes, + pageSplitOptions, + ReadingProfile, + ReadingProfileKind +} from "../../../_models/preferences/reading-profiles"; +import {ReadingProfileService} from "../../../_services/reading-profile.service"; +import {ConfirmService} from "../../../shared/confirm.service"; const PREFETCH_PAGES = 10; @@ -122,10 +129,10 @@ enum KeyDirection { ]) ]) ], - imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent, - DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent, - NgxSliderModule, ReactiveFormsModule, FittingIconPipe, ReaderModeIconPipe, - FullscreenIconPipe, TranslocoDirective, PercentPipe, NgClass, AsyncPipe, DblClickDirective] + imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent, + DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent, + NgxSliderModule, ReactiveFormsModule, FittingIconPipe, ReaderModeIconPipe, + FullscreenIconPipe, TranslocoDirective, PercentPipe, NgClass, AsyncPipe, DblClickDirective, NgbTooltip] }) export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { @@ -150,9 +157,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly modalService = inject(NgbModal); private readonly cdRef = inject(ChangeDetectorRef); private readonly toastr = inject(ToastrService); - public readonly readerService = inject(ReaderService); - public readonly utilityService = inject(UtilityService); - public readonly mangaReaderService = inject(MangaReaderService); + private readonly readingProfileService = inject(ReadingProfileService); + private readonly confirmService = inject(ConfirmService); + protected readonly readerService = inject(ReaderService); + protected readonly utilityService = inject(UtilityService); + protected readonly mangaReaderService = inject(MangaReaderService); + protected readonly KeyDirection = KeyDirection; protected readonly ReaderMode = ReaderMode; @@ -194,6 +204,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { totalSeriesPages = 0; totalSeriesPagesRead = 0; user!: User; + readingProfile!: ReadingProfile; + /** + * The reading profile itself, unless readingProfile is implicit + */ + parentReadingProfile: ReadingProfile | null = null; generalSettingsForm!: FormGroup; readingDirection = ReadingDirection.LeftToRight; @@ -488,6 +503,17 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true'; this.bookmarkMode = this.route.snapshot.queryParamMap.get('bookmarkMode') === 'true'; + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.readingProfile = data['readingProfile']; + if (this.readingProfile == null) { + this.router.navigateByUrl('/home'); + return; + } + // Requires seriesId to be set + this.setupReaderSettings(); + this.cdRef.markForCheck(); + }); + const readingListId = this.route.snapshot.queryParamMap.get('readingListId'); if (readingListId != null) { this.readingListMode = true; @@ -502,101 +528,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return; } + this.user = user; this.hasBookmarkRights = this.accountService.hasBookmarkRole(user) || this.accountService.hasAdminRole(user); - this.readingDirection = this.user.preferences.readingDirection; - this.scalingOption = this.user.preferences.scalingOption; - this.pageSplitOption = this.user.preferences.pageSplitOption; - this.autoCloseMenu = this.user.preferences.autoCloseMenu; - this.readerMode = this.user.preferences.readerMode; - this.layoutMode = this.user.preferences.layoutMode || LayoutMode.Single; - this.backgroundColor = this.user.preferences.backgroundColor || '#000000'; - this.readerService.setOverrideStyles(this.backgroundColor); - - this.generalSettingsForm = this.formBuilder.nonNullable.group({ - autoCloseMenu: new FormControl(this.autoCloseMenu), - pageSplitOption: new FormControl(this.pageSplitOption), - fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)), - widthSlider: new FormControl('none'), - layoutMode: new FormControl(this.layoutMode), - darkness: new FormControl(100), - emulateBook: new FormControl(this.user.preferences.emulateBook), - swipeToPaginate: new FormControl(this.user.preferences.swipeToPaginate) - }); - - this.readerModeSubject.next(this.readerMode); - this.pagingDirectionSubject.next(this.pagingDirection); - - // We need a mergeMap when page changes - this.readerSettings$ = merge(this.generalSettingsForm.valueChanges, this.pagingDirection$, this.readerMode$).pipe( - map(_ => this.createReaderSettingsUpdate()), - takeUntilDestroyed(this.destroyRef), - ); - - this.updateForm(); - - this.pagingDirection$.pipe( - distinctUntilChanged(), - tap(dir => { - this.pagingDirection = dir; - this.cdRef.markForCheck(); - }), - takeUntilDestroyed(this.destroyRef) - ).subscribe(() => {}); - - this.readerMode$.pipe( - distinctUntilChanged(), - tap(mode => { - this.readerMode = mode; - this.disableDoubleRendererIfScreenTooSmall(); - this.cdRef.markForCheck(); - }), - takeUntilDestroyed(this.destroyRef) - ).subscribe(() => {}); - - this.setupWidthOverrideTriggers(); - - this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { - - const changeOccurred = parseInt(val, 10) !== this.layoutMode; - this.layoutMode = parseInt(val, 10); - - if (this.layoutMode === LayoutMode.Single) { - this.generalSettingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption); - this.generalSettingsForm.get('pageSplitOption')?.enable(); - this.generalSettingsForm.get('widthSlider')?.enable(); - this.generalSettingsForm.get('fittingOption')?.enable(); - this.generalSettingsForm.get('emulateBook')?.enable(); - } else { - this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.NoSplit); - this.generalSettingsForm.get('pageSplitOption')?.disable(); - this.generalSettingsForm.get('widthSlider')?.disable(); - this.generalSettingsForm.get('fittingOption')?.setValue(this.mangaReaderService.translateScalingOption(ScalingOption.FitToHeight)); - this.generalSettingsForm.get('fittingOption')?.disable(); - this.generalSettingsForm.get('emulateBook')?.enable(); - } - this.cdRef.markForCheck(); - - // Re-render the current page when we switch layouts - if (changeOccurred) { - this.setPageNum(this.adjustPagesForDoubleRenderer(this.pageNum)); - this.loadPage(); - } - }); - - this.generalSettingsForm.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { - this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value; - this.pageSplitOption = parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10); - - const needsSplitting = this.mangaReaderService.isWidePage(this.readerService.imageUrlToPageNum(this.canvasImage.src)); - // If we need to split on a menu change, then we need to re-render. - if (needsSplitting) { - // If we need to re-render, to ensure things layout properly, let's update paging direction & reset render - this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD); - this.canvasRenderer.reset(); - this.loadPage(); - } - }); this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(progress => { if (!progress) { @@ -604,9 +538,29 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.toastr.info(translate('manga-reader.first-time-reading-manga')); } }); - }); - this.init(); + this.init(); + + // Update implicit reading profile while changing settings + this.generalSettingsForm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef), + map(_ => this.packReadingProfile()), + distinctUntilChanged(), + tap(newProfile => { + this.readingProfileService.updateImplicit(newProfile, this.seriesId).subscribe({ + next: updatedProfile => { + this.readingProfile = updatedProfile; + this.cdRef.markForCheck(); + }, + error: err => { + console.error(err); + } + }) + }) + ).subscribe(); + }); } ngAfterViewInit() { @@ -647,7 +601,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } @HostListener('window:keyup', ['$event']) - handleKeyPress(event: KeyboardEvent) { + async handleKeyPress(event: KeyboardEvent) { switch (this.readerMode) { case ReaderMode.LeftRight: if (event.key === KEY_CODES.RIGHT_ARROW) { @@ -682,7 +636,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else if (event.key === KEY_CODES.SPACE) { this.toggleMenu(); } else if (event.key === KEY_CODES.G) { - const goToPageNum = this.promptForPage(); + const goToPageNum = await this.promptForPage(); if (goToPageNum === null) { return; } this.goToPage(parseInt(goToPageNum.trim(), 10)); } else if (event.key === KEY_CODES.B) { @@ -694,6 +648,114 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + setupReaderSettings() { + + if (this.readingProfile.kind === ReadingProfileKind.Implicit) { + this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => { + this.parentReadingProfile = parent; + this.cdRef.markForCheck(); + }) + } else { + this.parentReadingProfile = this.readingProfile; + } + + this.readingDirection = this.readingProfile.readingDirection; + this.scalingOption = this.readingProfile.scalingOption; + this.pageSplitOption = this.readingProfile.pageSplitOption; + this.autoCloseMenu = this.readingProfile.autoCloseMenu; + this.readerMode = this.readingProfile.readerMode; + this.layoutMode = this.readingProfile.layoutMode || LayoutMode.Single; + this.backgroundColor = this.readingProfile.backgroundColor || '#000000'; + this.readerService.setOverrideStyles(this.backgroundColor); + + this.generalSettingsForm = this.formBuilder.nonNullable.group({ + autoCloseMenu: new FormControl(this.autoCloseMenu), + pageSplitOption: new FormControl(this.pageSplitOption), + fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)), + widthSlider: new FormControl(this.readingProfile.widthOverride ?? 'none'), + layoutMode: new FormControl(this.layoutMode), + darkness: new FormControl(100), + emulateBook: new FormControl(this.readingProfile.emulateBook), + swipeToPaginate: new FormControl(this.readingProfile.swipeToPaginate) + }); + + this.readerModeSubject.next(this.readerMode); + this.pagingDirectionSubject.next(this.pagingDirection); + + // We need a mergeMap when page changes + this.readerSettings$ = merge(this.generalSettingsForm.valueChanges, this.pagingDirection$, this.readerMode$).pipe( + map(_ => this.createReaderSettingsUpdate()), + takeUntilDestroyed(this.destroyRef), + ); + + this.updateForm(); + + this.pagingDirection$.pipe( + distinctUntilChanged(), + tap(dir => { + this.pagingDirection = dir; + this.cdRef.markForCheck(); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => {}); + + this.readerMode$.pipe( + distinctUntilChanged(), + tap(mode => { + this.readerMode = mode; + this.disableDoubleRendererIfScreenTooSmall(); + this.cdRef.markForCheck(); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => {}); + + this.setupWidthOverrideTriggers(); + + this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + + const changeOccurred = parseInt(val, 10) !== this.layoutMode; + this.layoutMode = parseInt(val, 10); + + if (this.layoutMode === LayoutMode.Single) { + this.generalSettingsForm.get('pageSplitOption')?.setValue(this.readingProfile!.pageSplitOption); + this.generalSettingsForm.get('pageSplitOption')?.enable(); + this.generalSettingsForm.get('widthSlider')?.enable(); + this.generalSettingsForm.get('fittingOption')?.enable(); + this.generalSettingsForm.get('emulateBook')?.enable(); + } else { + this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.NoSplit); + this.generalSettingsForm.get('pageSplitOption')?.disable(); + this.generalSettingsForm.get('widthSlider')?.disable(); + this.generalSettingsForm.get('fittingOption')?.setValue(this.mangaReaderService.translateScalingOption(ScalingOption.FitToHeight)); + this.generalSettingsForm.get('fittingOption')?.disable(); + this.generalSettingsForm.get('emulateBook')?.enable(); + } + this.cdRef.markForCheck(); + + // Re-render the current page when we switch layouts + if (changeOccurred) { + this.setPageNum(this.adjustPagesForDoubleRenderer(this.pageNum)); + this.loadPage(); + } + }); + + this.generalSettingsForm.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value; + this.pageSplitOption = parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10); + + const needsSplitting = this.mangaReaderService.isWidePage(this.readerService.imageUrlToPageNum(this.canvasImage.src)); + // If we need to split on a menu change, then we need to re-render. + if (needsSplitting) { + // If we need to re-render, to ensure things layout properly, let's update paging direction & reset render + this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD); + this.canvasRenderer.reset(); + this.loadPage(); + } + }); + + this.cdRef.markForCheck(); + } + /** * Width override is only valid under the following conditions: * Image Scaling is Width @@ -747,7 +809,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { ).subscribe(); // Set the default override to 0 - widthOverrideControl.setValue(0); + //widthOverrideControl.setValue(0); //send the current width override value to the label this.widthOverrideLabel$ = this.readerSettings$?.pipe( @@ -780,7 +842,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { switchToWebtoonReaderIfPagesLikelyWebtoon() { if (this.readerMode === ReaderMode.Webtoon) return; - if (!this.user.preferences.allowAutomaticWebtoonReaderDetection) return; + if (!this.readingProfile!.allowAutomaticWebtoonReaderDetection) return; if (this.mangaReaderService.shouldBeWebtoonMode()) { this.readerMode = ReaderMode.Webtoon; @@ -792,7 +854,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { disableDoubleRendererIfScreenTooSmall() { if (window.innerWidth > window.innerHeight) { - this.generalSettingsForm.get('layoutMode')?.enable(); + if (this.generalSettingsForm.get('layoutMode')?.disabled) { + this.generalSettingsForm.get('layoutMode')?.enable(); + } this.cdRef.markForCheck(); return; } @@ -1219,8 +1283,19 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + /** + * Calls the correct prev- or nextPage method based on the direction, readingDirection, and readerMode + * + * readingDirection is ignored when readerMode is Webtoon or UpDown + * + * KeyDirection.Right: right or bottom click + * KeyDirection.Left: left or top click + * @param event + * @param direction + */ handlePageChange(event: any, direction: KeyDirection) { - if (this.readerMode === ReaderMode.Webtoon) { + // Webtoons and UpDown reading mode should not take ReadingDirection into account + if (this.readerMode === ReaderMode.Webtoon || this.readerMode === ReaderMode.UpDown) { if (direction === KeyDirection.Right) { this.nextPage(event); } else { @@ -1228,6 +1303,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } return; } + if (direction === KeyDirection.Right) { this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage(event) : this.prevPage(event); } else if (direction === KeyDirection.Left) { @@ -1460,7 +1536,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.readingDirection = ReadingDirection.LeftToRight; } - if (this.menuOpen && this.user.preferences.showScreenHints) { + if (this.menuOpen && this.readingProfile!.showScreenHints) { this.showClickOverlay = true; this.showClickOverlaySubject.next(true); setTimeout(() => { @@ -1593,9 +1669,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } // This is menu only code - promptForPage() { - const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages}); - const goToPageNum = window.prompt(question, ''); + async promptForPage() { + // const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages}); + // const goToPageNum = window.prompt(question, ''); + + const promptConfig = {...this.confirmService.defaultPrompt}; + promptConfig.header = translate('book-reader.go-to-page'); + promptConfig.content = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages}); + + const goToPageNum = await this.confirmService.prompt(undefined, promptConfig); + if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; } return goToPageNum; } @@ -1730,28 +1813,28 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } // menu only code - savePref() { - const modelSettings = this.generalSettingsForm.getRawValue(); - // Get latest preferences from user, overwrite with what we manage in this UI, then save - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (!user) return; - const data = {...user.preferences}; - data.layoutMode = parseInt(modelSettings.layoutMode, 10); - data.readerMode = this.readerMode; - data.autoCloseMenu = this.autoCloseMenu; - data.readingDirection = this.readingDirection; - data.emulateBook = modelSettings.emulateBook; - data.swipeToPaginate = modelSettings.swipeToPaginate; - data.pageSplitOption = parseInt(modelSettings.pageSplitOption, 10); - data.locale = data.locale || 'en'; + updateParentPref() { + if (this.readingProfile.kind !== ReadingProfileKind.Implicit) { + return; + } - this.accountService.updatePreferences(data).subscribe(updatedPrefs => { - this.toastr.success(translate('manga-reader.user-preferences-updated')); - if (this.user) { - this.user.preferences = updatedPrefs; - this.cdRef.markForCheck(); - } - }) + this.readingProfileService.updateParentProfile(this.seriesId, this.packReadingProfile()).subscribe(newProfile => { + this.readingProfile = newProfile; + this.toastr.success(translate('manga-reader.reading-profile-updated')); + this.cdRef.markForCheck(); + }); + } + + createNewProfileFromImplicit() { + if (this.readingProfile.kind !== ReadingProfileKind.Implicit) { + return; + } + + this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => { + this.readingProfile = newProfile; + this.parentReadingProfile = newProfile; // Profile is no longer implicit + this.toastr.success(translate("manga-reader.reading-profile-promoted")); + this.cdRef.markForCheck(); }); } @@ -1761,4 +1844,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return d; } + private packReadingProfile(): ReadingProfile { + const modelSettings = this.generalSettingsForm.getRawValue(); + const data = {...this.readingProfile!}; + + data.layoutMode = parseInt(modelSettings.layoutMode, 10); + data.readerMode = this.readerMode; + data.autoCloseMenu = this.autoCloseMenu; + data.readingDirection = this.readingDirection; + data.emulateBook = modelSettings.emulateBook; + data.swipeToPaginate = modelSettings.swipeToPaginate; + data.pageSplitOption = parseInt(modelSettings.pageSplitOption, 10); + data.widthOverride = modelSettings.widthSlider === 'none' ? null : modelSettings.widthSlider; + + return data; + } + + protected readonly ReadingProfileKind = ReadingProfileKind; } diff --git a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html index a0ebb4abc..2dcb81889 100644 --- a/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html +++ b/UI/Web/src/app/manga-reader/_components/single-renderer/single-renderer.component.html @@ -3,7 +3,7 @@ [style.filter]="(darkness$ | async) ?? '' | safeStyle" [style.height]="(imageContainerHeight$ | async) ?? '' | safeStyle"> @if(currentImage) {  ; + @Input({required: true}) readingProfile!: ReadingProfile; @Input({required: true}) image$!: Observable; @Input({required: true}) bookmark$!: Observable; @Input({required: true}) showClickOverlay$!: Observable; @@ -52,16 +58,14 @@ export class SingleRendererComponent implements OnInit, ImageRenderer { pageNum: number = 0; maxPages: number = 1; - /** - * Width override for maunal width control - */ - widthOverride$ : Observable = new Observable(); + readerSettings!: Signal; + widthOverride!: Signal; get ReaderMode() {return ReaderMode;} get LayoutMode() {return LayoutMode;} constructor(private readonly cdRef: ChangeDetectorRef, public mangaReaderService: MangaReaderService, - @Inject(DOCUMENT) private document: Document) { } + @Inject(DOCUMENT) private document: Document) {} ngOnInit(): void { this.readerModeClass$ = this.readerSettings$.pipe( @@ -71,12 +75,16 @@ export class SingleRendererComponent implements OnInit, ImageRenderer { takeUntilDestroyed(this.destroyRef) ); - //handle manual width - this.widthOverride$ = this.readerSettings$.pipe( - map(values => (parseInt(values.widthSlider) <= 0) ? '' : values.widthSlider + '%'), - takeUntilDestroyed(this.destroyRef) - ); + this.readerSettings = toSignal(this.readerSettings$, {injector: this.injector, requireSync: true}); + this.widthOverride = computed(() => { + const breakpoint = this.utilityService.activeUserBreakpoint(); + const value = this.readerSettings().widthSlider; + if (breakpoint <= this.readingProfile.disableWidthOverride) { + return ''; + } + return (parseInt(value) <= 0) ? '' : value + '%'; + }); this.emulateBookClass$ = this.readerSettings$.pipe( map(data => data.emulateBook), diff --git a/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.html b/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.html index 907944aeb..1fe4aca29 100644 --- a/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.html +++ b/UI/Web/src/app/metadata-filter/_components/metadata-builder/metadata-builder.component.html @@ -24,7 +24,7 @@ @for (filterStmt of filter.statements; track filterStmt; let i = $index) {
    - +
    @if (i < (filter.statements.length - 1) && filter.statements.length > 1) { +
    -
    - - -
    + + @if (filterSettings().supportsSmartFilter) { +
    + + +
    + } + @if (utilityService.getActiveBreakpoint() > Breakpoint.Tablet) { @@ -82,7 +80,7 @@
    - diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index c65bb5c16..d1fc264ef 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -2,55 +2,61 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, ContentChild, DestroyRef, + effect, EventEmitter, inject, + input, Input, OnInit, - Output + Output, + Signal } from '@angular/core'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {NgbCollapse} from '@ng-bootstrap/ng-bootstrap'; import {Breakpoint, UtilityService} from '../shared/_services/utility.service'; import {Library} from '../_models/library/library'; -import {allSortFields, FilterEvent, FilterItem, SortField} from '../_models/metadata/series-filter'; +import {FilterEvent, FilterItem} from '../_models/metadata/series-filter'; import {ToggleService} from '../_services/toggle.service'; -import {FilterSettings} from './filter-settings'; -import {SeriesFilterV2} from '../_models/metadata/v2/series-filter-v2'; +import {FilterV2} from '../_models/metadata/v2/filter-v2'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {DrawerComponent} from '../shared/drawer/drawer.component'; import {AsyncPipe, NgClass, NgTemplateOutlet} from '@angular/common'; import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco"; -import {SortFieldPipe} from "../_pipes/sort-field.pipe"; import {MetadataBuilderComponent} from "./_components/metadata-builder/metadata-builder.component"; -import {allFields} from "../_models/metadata/v2/filter-field"; import {FilterService} from "../_services/filter.service"; import {ToastrService} from "ngx-toastr"; import {animate, style, transition, trigger} from "@angular/animations"; +import {SortButtonComponent} from "../_single-module/sort-button/sort-button.component"; +import {FilterSettingsBase} from "./filter-settings"; +import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; + @Component({ - selector: 'app-metadata-filter', - templateUrl: './metadata-filter.component.html', - styleUrls: ['./metadata-filter.component.scss'], - animations: [ - trigger('inOutAnimation', [ - transition(':enter', [ - style({ height: 0, opacity: 0 }), - animate('.5s ease-out', style({ height: 300, opacity: 1 })) - ]), - transition(':leave', [ - style({ height: 300, opacity: 1 }), - animate('.5s ease-in', style({ height: 0, opacity: 0 })) - ]) - ]), - ], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgTemplateOutlet, DrawerComponent, - ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule, - MetadataBuilderComponent, NgClass] + selector: 'app-metadata-filter', + templateUrl: './metadata-filter.component.html', + styleUrls: ['./metadata-filter.component.scss'], + animations: [ + trigger('inOutAnimation', [ + transition(':enter', [ + style({ height: 0, opacity: 0 }), + animate('.5s ease-out', style({ height: 300, opacity: 1 })) + ]), + transition(':leave', [ + style({ height: 300, opacity: 1 }), + animate('.5s ease-in', style({ height: 0, opacity: 0 })) + ]) + ]), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgTemplateOutlet, DrawerComponent, + ReactiveFormsModule, FormsModule, AsyncPipe, TranslocoModule, + MetadataBuilderComponent, NgClass, SortButtonComponent] }) -export class MetadataFilterComponent implements OnInit { +export class MetadataFilterComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); public readonly utilityService = inject(UtilityService); @@ -59,18 +65,16 @@ export class MetadataFilterComponent implements OnInit { private readonly filterService = inject(FilterService); protected readonly toggleService = inject(ToggleService); protected readonly translocoService = inject(TranslocoService); - private readonly sortFieldPipe = new SortFieldPipe(this.translocoService); + protected readonly filterUtilitiesService = inject(FilterUtilitiesService); /** * This toggles the opening/collapsing of the metadata filter code */ @Input() filterOpen: EventEmitter = new EventEmitter(); - /** - * Should filtering be shown on the page - */ - @Input() filteringDisabled: boolean = false; - @Input({required: true}) filterSettings!: FilterSettings; - @Output() applyFilter: EventEmitter = new EventEmitter(); + + filterSettings = input.required>(); + + @Output() applyFilter: EventEmitter> = new EventEmitter(); @ContentChild('[ngbCollapse]') collapse!: NgbCollapse; @@ -86,20 +90,23 @@ export class MetadataFilterComponent implements OnInit { updateApplied: number = 0; fullyLoaded: boolean = false; - filterV2: SeriesFilterV2 | undefined; + filterV2: FilterV2 | undefined; + sortFieldOptions: Signal<{title: string, value: number}[]> = computed(() => []); + filterFieldOptions: Signal<{title: string, value: number}[]> = computed(() => []); + + constructor() { + effect(() => { + const settings = this.filterSettings(); + if (settings?.presetsV2) { + this.filterV2 = this.deepClone(settings.presetsV2); + this.cdRef.markForCheck(); + } + }) + } - protected readonly allSortFields = allSortFields.map(f => { - return {title: this.sortFieldPipe.transform(f), value: f}; - }).sort((a, b) => a.title.localeCompare(b.title)); - protected readonly allFilterFields = allFields; ngOnInit(): void { - if (this.filterSettings === undefined) { - this.filterSettings = new FilterSettings(); - this.cdRef.markForCheck(); - } - if (this.filterOpen) { this.filterOpen.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(openState => { this.filteringCollapsed = !openState; @@ -109,42 +116,19 @@ export class MetadataFilterComponent implements OnInit { } + this.filterFieldOptions = computed(() => { + return this.filterUtilitiesService.getFilterFields(this.filterSettings().type); + }); + + this.sortFieldOptions = computed(() => { + return this.filterUtilitiesService.getSortFields(this.filterSettings().type); + }); + + this.loadFromPresetsAndSetup(); } - // loadSavedFilter(event: Select2UpdateEvent) { - // // Load the filter from the backend and update the screen - // if (event.value === undefined || typeof(event.value) === 'string') return; - // const smartFilter = event.value as SmartFilter; - // this.filterV2 = this.filterUtilitiesService.decodeSeriesFilter(smartFilter.filter); - // this.cdRef.markForCheck(); - // console.log('update event: ', event); - // } - // - // createFilterValue(event: Select2AutoCreateEvent) { - // // Create a new name and filter - // if (!this.filterV2) return; - // this.filterV2.name = event.value; - // this.filterService.saveFilter(this.filterV2).subscribe(() => { - // - // const item = { - // value: { - // filter: this.filterUtilitiesService.encodeSeriesFilter(this.filterV2!), - // name: event.value, - // } as SmartFilter, - // label: event.value - // }; - // this.smartFilters.push(item); - // this.sortGroup.get('name')?.setValue(item); - // this.cdRef.markForCheck(); - // this.toastr.success(translate('toasts.smart-filter-updated')); - // this.apply(); - // }); - // - // console.log('create event: ', event); - // } - close() { this.filterOpen.emit(false); @@ -177,7 +161,7 @@ export class MetadataFilterComponent implements OnInit { return clonedObj; } - handleFilters(filter: SeriesFilterV2) { + handleFilters(filter: FilterV2) { this.filterV2 = filter; } @@ -185,29 +169,34 @@ export class MetadataFilterComponent implements OnInit { loadFromPresetsAndSetup() { this.fullyLoaded = false; - this.filterV2 = this.deepClone(this.filterSettings.presetsV2); + const currentFilterSettings = this.filterSettings(); + this.filterV2 = this.deepClone(currentFilterSettings.presetsV2); + + const defaultSortField = this.sortFieldOptions()[0].value; this.sortGroup = new FormGroup({ - sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || SortField.SortName, disabled: this.filterSettings.sortDisabled}, []), + sortField: new FormControl({value: this.filterV2?.sortOptions?.sortField || defaultSortField, disabled: this.filterSettings().sortDisabled}, []), limitTo: new FormControl(this.filterV2?.limitTo || 0, []), name: new FormControl(this.filterV2?.name || '', []) }); - if (this.filterSettings?.presetsV2?.sortOptions) { - this.isAscendingSort = this.filterSettings?.presetsV2?.sortOptions!.isAscending; + + if (this.filterSettings()?.presetsV2?.sortOptions) { + this.isAscendingSort = this.filterSettings()?.presetsV2?.sortOptions!.isAscending || true; } + this.cdRef.markForCheck(); this.sortGroup.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { - if (this.filterV2?.sortOptions === null) { - this.filterV2.sortOptions = { - isAscending: this.isAscendingSort, - sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) - }; - } - this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10); - this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0); - this.filterV2!.name = this.sortGroup.get('name')?.value || ''; - this.cdRef.markForCheck(); + if (this.filterV2?.sortOptions === null) { + this.filterV2.sortOptions = { + isAscending: this.isAscendingSort, + sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) as TSort + }; + } + this.filterV2!.sortOptions!.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10) as TSort; + this.filterV2!.limitTo = Math.max(parseInt(this.sortGroup.get('limitTo')?.value || '0', 10), 0); + this.filterV2!.name = this.sortGroup.get('name')?.value || ''; + this.cdRef.markForCheck(); }); this.fullyLoaded = true; @@ -215,13 +204,16 @@ export class MetadataFilterComponent implements OnInit { } - updateSortOrder() { - if (this.filterSettings.sortDisabled) return; - this.isAscendingSort = !this.isAscendingSort; + updateSortOrder(isAscending: boolean) { + if (this.filterSettings().sortDisabled) return; + this.isAscendingSort = isAscending; + if (this.filterV2?.sortOptions === null) { + const defaultSortField = this.sortFieldOptions()[0].value as TSort; + this.filterV2.sortOptions = { isAscending: this.isAscendingSort, - sortField: SortField.SortName + sortField: defaultSortField } } @@ -235,7 +227,7 @@ export class MetadataFilterComponent implements OnInit { } apply() { - this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!}); + this.applyFilter.emit({isFirst: this.updateApplied === 0, filterV2: this.filterV2!} as FilterEvent); if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile && this.updateApplied !== 0) { this.toggleSelected(); @@ -259,9 +251,6 @@ export class MetadataFilterComponent implements OnInit { this.cdRef.markForCheck(); } - setToggle(event: any) { - this.toggleService.set(!this.filteringCollapsed); - } protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html index 6f36e9b5a..4a51435fc 100644 --- a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html +++ b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html @@ -16,7 +16,7 @@ } } @else { -
    +
    Ctrl+K
    } diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html index 9e0f26a0a..089262875 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html @@ -206,6 +206,8 @@
    {{t('all-filters')}} + {{t('browse-genres')}} + {{t('browse-tags')}} {{t('announcements')}} {{t('help')}} {{t('logout')}} diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts index 980a1be55..fd4af01f0 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts @@ -47,6 +47,7 @@ import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.comp import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service"; import {WikiLink} from "../../../_models/wiki"; import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component"; +import {MetadataService} from "../../../_services/metadata.service"; @Component({ selector: 'app-nav-header', @@ -70,6 +71,8 @@ export class NavHeaderComponent implements OnInit { protected readonly imageService = inject(ImageService); protected readonly utilityService = inject(UtilityService); protected readonly modalService = inject(NgbModal); + protected readonly metadataService = inject(MetadataService); + protected readonly FilterField = FilterField; protected readonly WikiLink = WikiLink; @@ -159,9 +162,9 @@ export class NavHeaderComponent implements OnInit { }); } - goTo(statement: FilterStatement) { + goTo(statement: FilterStatement) { let params: any = {}; - const filter = this.filterUtilityService.createSeriesV2Filter(); + const filter = this.metadataService.createDefaultFilterDto('series'); filter.statements = [statement]; params['page'] = 1; this.clearSearch(); diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts index b6e99b5ca..c6b7c0494 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts @@ -34,6 +34,9 @@ import {PdfSpreadMode} from "../../../_models/preferences/pdf-spread-mode"; import {SpreadType} from "ngx-extended-pdf-viewer/lib/options/spread-type"; import {PdfScrollModeTypePipe} from "../../_pipe/pdf-scroll-mode.pipe"; import {PdfSpreadTypePipe} from "../../_pipe/pdf-spread-mode.pipe"; +import {ReadingProfileService} from "../../../_services/reading-profile.service"; +import {ReadingProfile} from "../../../_models/preferences/reading-profiles"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Component({ selector: 'app-pdf-reader', @@ -54,6 +57,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy { private readonly themeService = inject(ThemeService); private readonly cdRef = inject(ChangeDetectorRef); public readonly accountService = inject(AccountService); + private readonly readingProfileService = inject(ReadingProfileService); public readonly readerService = inject(ReaderService); public readonly utilityService = inject(UtilityService); public readonly destroyRef = inject(DestroyRef); @@ -69,6 +73,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy { chapterId!: number; chapter!: Chapter; user!: User; + readingProfile!: ReadingProfile; /** * Reading List id. Defaults to -1. @@ -162,6 +167,16 @@ export class PdfReaderComponent implements OnInit, OnDestroy { this.chapterId = parseInt(chapterId, 10); this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true'; + this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { + this.readingProfile = data['readingProfile']; + if (this.readingProfile == null) { + this.router.navigateByUrl('/home'); + return; + } + this.setupReaderSettings(); + this.cdRef.markForCheck(); + }); + const readingListId = this.route.snapshot.queryParamMap.get('readingListId'); if (readingListId != null) { @@ -234,12 +249,14 @@ export class PdfReaderComponent implements OnInit, OnDestroy { } } - init() { - + setupReaderSettings() { this.pageLayoutMode = this.convertPdfLayoutMode(PdfLayoutMode.Multiple); - this.scrollMode = this.convertPdfScrollMode(this.user.preferences.pdfScrollMode || PdfScrollMode.Vertical); - this.spreadMode = this.convertPdfSpreadMode(this.user.preferences.pdfSpreadMode || PdfSpreadMode.None); - this.theme = this.convertPdfTheme(this.user.preferences.pdfTheme || PdfTheme.Dark); + this.scrollMode = this.convertPdfScrollMode(this.readingProfile.pdfScrollMode || PdfScrollMode.Vertical); + this.spreadMode = this.convertPdfSpreadMode(this.readingProfile.pdfSpreadMode || PdfSpreadMode.None); + this.theme = this.convertPdfTheme(this.readingProfile.pdfTheme || PdfTheme.Dark); + } + + init() { this.backgroundColor = this.themeMap[this.theme].background; this.fontColor = this.themeMap[this.theme].font; // TODO: Move this to an observable or something diff --git a/UI/Web/src/app/person-detail/person-detail.component.ts b/UI/Web/src/app/person-detail/person-detail.component.ts index 31b7f976b..ab3c486bd 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.ts +++ b/UI/Web/src/app/person-detail/person-detail.component.ts @@ -23,7 +23,7 @@ import {PersonRolePipe} from "../_pipes/person-role.pipe"; import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {FilterV2} from "../_models/metadata/v2/filter-v2"; import {allPeople, FilterField, personRoleForFilterField} from "../_models/metadata/v2/filter-field"; import {Series} from "../_models/series"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -44,6 +44,7 @@ import {SafeUrlPipe} from "../_pipes/safe-url.pipe"; import {MergePersonModalComponent} from "./_modal/merge-person-modal/merge-person-modal.component"; import {EVENTS, MessageHubService} from "../_services/message-hub.service"; import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; +import {MetadataService} from "../_services/metadata.service"; interface PersonMergeEvent { srcId: number, @@ -87,6 +88,7 @@ export class PersonDetailComponent implements OnInit { private readonly themeService = inject(ThemeService); private readonly toastr = inject(ToastrService); private readonly messageHubService = inject(MessageHubService) + private readonly metadataService = inject(MetadataService) protected readonly FilterField = FilterField; @@ -98,7 +100,7 @@ export class PersonDetailComponent implements OnInit { roles$: Observable | null = null; roles: PersonRole[] | null = null; works$: Observable | null = null; - filter: SeriesFilterV2 | null = null; + filter: FilterV2 | null = null; personActions: Array> = this.actionService.getPersonActions(this.handleAction.bind(this)); chaptersByRole: any = {}; anilistUrl: string = ''; @@ -181,7 +183,7 @@ export class PersonDetailComponent implements OnInit { } createFilter(roles: PersonRole[]) { - const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter(); + const filter = this.metadataService.createDefaultFilterDto('series'); filter.combination = FilterCombination.Or; filter.limitTo = 20; @@ -217,7 +219,7 @@ export class PersonDetailComponent implements OnInit { params['page'] = 1; params['title'] = translate('person-detail.browse-person-by-role-title', {name: this.person!.name, role: personPipe.transform(role)}); - const searchFilter = this.filterUtilityService.createSeriesV2Filter(); + const searchFilter = this.metadataService.createDefaultFilterDto('series'); searchFilter.limitTo = 0; searchFilter.combination = FilterCombination.Or; diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 9f45cd55a..1d1ce4c7e 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -83,7 +83,7 @@ } -
    +
    diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index 511811fe8..6e8e3b22a 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -58,6 +58,7 @@ import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {DetailsTabComponent} from "../../../_single-module/details-tab/details-tab.component"; import {IHasCast} from "../../../_models/common/i-has-cast"; +import {User} from "../../../_models/user"; enum TabID { Storyline = 'storyline-tab', @@ -251,7 +252,8 @@ export class ReadingListDetailComponent implements OnInit { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); - this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) + this.actions = this.actionFactoryService + .getReadingListActions(this.handleReadingListActionCallback.bind(this), this.shouldRenderReadingListAction.bind(this)) .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); this.isOwnedReadingList = this.actions.filter(a => a.action === Action.Edit).length > 0; this.cdRef.markForCheck(); @@ -307,6 +309,17 @@ export class ReadingListDetailComponent implements OnInit { } } + shouldRenderReadingListAction(action: ActionItem, entity: ReadingList, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } + editReadingList(readingList: ReadingList) { this.actionService.editReadingList(readingList, (readingList: ReadingList) => { // Reload information around list diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html index dd7dcab9a..a66ec008f 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html @@ -21,7 +21,7 @@ [trackByIdentity]="trackByIdentity" > - this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); - - return this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) + return this.actionFactoryService + .getReadingListActions(this.handleReadingListActionCallback.bind(this), this.shouldRenderReadingListAction.bind(this)) .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); } @@ -172,4 +172,15 @@ export class ReadingListsComponent implements OnInit { break; } } + + shouldRenderReadingListAction(action: ActionItem, entity: ReadingList, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 05580bed0..4035f2f41 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -110,6 +110,7 @@ import {LicenseService} from "../../../_services/license.service"; import {PageBookmark} from "../../../_models/readers/page-bookmark"; import {VolumeRemovedEvent} from "../../../_models/events/volume-removed-event"; import {ReviewsComponent} from "../../../_single-module/reviews/reviews.component"; +import {ReadingProfileService} from "../../../_services/reading-profile.service"; enum TabID { @@ -175,6 +176,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { private readonly cdRef = inject(ChangeDetectorRef); private readonly scrollService = inject(ScrollService); private readonly translocoService = inject(TranslocoService); + private readonly readingProfileService = inject(ReadingProfileService); protected readonly bulkSelectionService = inject(BulkSelectionService); protected readonly utilityService = inject(UtilityService); protected readonly imageService = inject(ImageService); @@ -551,7 +553,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.location.replaceState(newUrl) } - handleSeriesActionCallback(action: ActionItem, series: Series) { + async handleSeriesActionCallback(action: ActionItem, series: Series) { this.cdRef.markForCheck(); switch(action.action) { case(Action.MarkAsRead): @@ -565,16 +567,16 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { }); break; case(Action.Scan): - this.actionService.scanSeries(series); + await this.actionService.scanSeries(series); break; case(Action.RefreshMetadata): - this.actionService.refreshSeriesMetadata(series, undefined, true, false); + await this.actionService.refreshSeriesMetadata(series, undefined, true, false); break; case(Action.GenerateColorScape): - this.actionService.refreshSeriesMetadata(series, undefined, false, true); + await this.actionService.refreshSeriesMetadata(series, undefined, false, true); break; case(Action.Delete): - this.deleteSeries(series); + await this.deleteSeries(series); break; case(Action.AddToReadingList): this.actionService.addSeriesToReadingList(series); @@ -609,6 +611,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.actionService.sendToDevice(chapterIds, device); break; } + case Action.SetReadingProfile: + this.actionService.setReadingProfileForMultiple([this.series]); + break; + case Action.ClearReadingProfile: + this.readingProfileService.clearSeriesProfiles(this.seriesId).subscribe(() => { + this.toastr.success(this.translocoService.translate('actionable.cleared-profile')); + }); + break; default: break; } @@ -645,6 +655,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.actionService.sendToDevice(volume.chapters.map(c => c.id), device); break; } + case (Action.Download): + this.downloadService.download('volume', volume); + break; default: break; } @@ -679,6 +692,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.cdRef.markForCheck(); }); break; + case (Action.Download): + this.downloadService.download('chapter', chapter); + break; default: break; } @@ -879,10 +895,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.cdRef.markForCheck(); } - - - - this.isLoading = false; this.cdRef.markForCheck(); }); @@ -1076,19 +1088,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { } - openVolume(volume: Volume) { - if (this.bulkSelectionService.hasSelections()) return; - if (volume.chapters === undefined || volume.chapters?.length === 0) { - this.toastr.error(this.translocoService.translate('series-detail.no-chapters')); - return; - } - - this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'volume', volume.id]); - return; - - - this.readerService.readVolume(this.libraryId, this.seriesId, volume, false); - } openEditChapter(chapter: Chapter) { const ref = this.modalService.open(EditChapterModalComponent, DefaultModalOptions); diff --git a/UI/Web/src/app/settings/_components/setting-switch/setting-switch.component.ts b/UI/Web/src/app/settings/_components/setting-switch/setting-switch.component.ts index 530fa2ff3..e39b6beee 100644 --- a/UI/Web/src/app/settings/_components/setting-switch/setting-switch.component.ts +++ b/UI/Web/src/app/settings/_components/setting-switch/setting-switch.component.ts @@ -51,7 +51,7 @@ export class SettingSwitchComponent implements AfterContentInit { const inputElement = element.querySelector('input'); // If no id, generate a random id and assign it to the input - inputElement.id = crypto.randomUUID(); + inputElement.id = this.generateId(); if (inputElement && inputElement.id) { this.labelId = inputElement.id; @@ -62,4 +62,13 @@ export class SettingSwitchComponent implements AfterContentInit { }); } + private generateId(): string { + if (crypto && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // Fallback for browsers without crypto.randomUUID (which has happened multiple times in my user base) + return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now().toString(36); + } + } diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.html b/UI/Web/src/app/settings/_components/settings/settings.component.html index 168c98a85..3e3d4f144 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -217,6 +217,15 @@
    } } + + @defer (when fragment === SettingsTabId.ReadingProfiles; prefetch on idle) { + @if (fragment === SettingsTabId.ReadingProfiles) { +
    + +
    + } + } + }
    diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.ts b/UI/Web/src/app/settings/_components/settings/settings.component.ts index 84b1bfe0a..d470972d0 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -52,43 +52,47 @@ import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobb import { ManageMetadataSettingsComponent } from "../../../admin/manage-metadata-settings/manage-metadata-settings.component"; +import { + ManageReadingProfilesComponent +} from "../../../user-settings/manage-reading-profiles/manage-reading-profiles.component"; @Component({ selector: 'app-settings', - imports: [ - ChangeAgeRestrictionComponent, - ChangeEmailComponent, - ChangePasswordComponent, - ManageDevicesComponent, - ManageOpdsComponent, - ManageScrobblingProvidersComponent, - ManageUserPreferencesComponent, - SideNavCompanionBarComponent, - ThemeManagerComponent, - TranslocoDirective, - UserStatsComponent, - AsyncPipe, - LicenseComponent, - ManageEmailSettingsComponent, - ManageLibraryComponent, - ManageMediaSettingsComponent, - ManageSettingsComponent, - ManageSystemComponent, - ManageTasksSettingsComponent, - ManageUsersComponent, - ServerStatsComponent, - SettingFragmentPipe, - ManageScrobblingComponent, - ManageMediaIssuesComponent, - ManageCustomizationComponent, - ImportMalCollectionComponent, - ImportCblComponent, - ManageMatchedMetadataComponent, - ManageUserTokensComponent, - EmailHistoryComponent, - ScrobblingHoldsComponent, - ManageMetadataSettingsComponent - ], + imports: [ + ChangeAgeRestrictionComponent, + ChangeEmailComponent, + ChangePasswordComponent, + ManageDevicesComponent, + ManageOpdsComponent, + ManageScrobblingProvidersComponent, + ManageUserPreferencesComponent, + SideNavCompanionBarComponent, + ThemeManagerComponent, + TranslocoDirective, + UserStatsComponent, + AsyncPipe, + LicenseComponent, + ManageEmailSettingsComponent, + ManageLibraryComponent, + ManageMediaSettingsComponent, + ManageSettingsComponent, + ManageSystemComponent, + ManageTasksSettingsComponent, + ManageUsersComponent, + ServerStatsComponent, + SettingFragmentPipe, + ManageScrobblingComponent, + ManageMediaIssuesComponent, + ManageCustomizationComponent, + ImportMalCollectionComponent, + ImportCblComponent, + ManageMatchedMetadataComponent, + ManageUserTokensComponent, + EmailHistoryComponent, + ScrobblingHoldsComponent, + ManageMetadataSettingsComponent, + ManageReadingProfilesComponent + ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 49d57efbc..184f31094 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -1,24 +1,16 @@ -import { HttpClient } from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {DestroyRef, inject, Inject, Injectable} from '@angular/core'; -import { Series } from 'src/app/_models/series'; -import { environment } from 'src/environments/environment'; -import { ConfirmService } from '../confirm.service'; -import { Chapter } from 'src/app/_models/chapter'; -import { Volume } from 'src/app/_models/volume'; -import { - asyncScheduler, - BehaviorSubject, - Observable, - tap, - finalize, - of, - filter, -} from 'rxjs'; -import { download, Download } from '../_models/download'; -import { PageBookmark } from 'src/app/_models/readers/page-bookmark'; +import {Series} from 'src/app/_models/series'; +import {environment} from 'src/environments/environment'; +import {ConfirmService} from '../confirm.service'; +import {Chapter} from 'src/app/_models/chapter'; +import {Volume} from 'src/app/_models/volume'; +import {asyncScheduler, BehaviorSubject, filter, finalize, Observable, of, tap,} from 'rxjs'; +import {download, Download} from '../_models/download'; +import {PageBookmark} from 'src/app/_models/readers/page-bookmark'; import {switchMap, take, takeWhile, throttleTime} from 'rxjs/operators'; -import { AccountService } from 'src/app/_services/account.service'; -import { BytesPipe } from 'src/app/_pipes/bytes.pipe'; +import {AccountService} from 'src/app/_services/account.service'; +import {BytesPipe} from 'src/app/_pipes/bytes.pipe'; import {translate} from "@jsverse/transloco"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {SAVER, Saver} from "../../_providers/saver.provider"; @@ -26,7 +18,7 @@ import {UtilityService} from "./utility.service"; import {UserCollection} from "../../_models/collection-tag"; import {RecentlyAddedItem} from "../../_models/recently-added-item"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; -import {BrowsePerson} from "../../_models/person/browse-person"; +import {BrowsePerson} from "../../_models/metadata/browse/browse-person"; export const DEBOUNCE_TIME = 100; diff --git a/UI/Web/src/app/shared/_services/filter-utilities.service.ts b/UI/Web/src/app/shared/_services/filter-utilities.service.ts index a8c615149..559a70ab1 100644 --- a/UI/Web/src/app/shared/_services/filter-utilities.service.ts +++ b/UI/Web/src/app/shared/_services/filter-utilities.service.ts @@ -1,18 +1,27 @@ import {inject, Injectable} from '@angular/core'; -import {ActivatedRouteSnapshot, Params, Router} from '@angular/router'; -import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter'; +import {Params, Router} from '@angular/router'; +import {allSeriesSortFields, SortField} from 'src/app/_models/metadata/series-filter'; import {MetadataService} from "../../_services/metadata.service"; -import {SeriesFilterV2} from "../../_models/metadata/v2/series-filter-v2"; -import {FilterStatement} from "../../_models/metadata/v2/filter-statement"; +import {FilterV2} from "../../_models/metadata/v2/filter-v2"; import {FilterCombination} from "../../_models/metadata/v2/filter-combination"; -import {FilterField} from "../../_models/metadata/v2/filter-field"; +import {allSeriesFilterFields, FilterField} from "../../_models/metadata/v2/filter-field"; import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; -import { HttpClient } from "@angular/common/http"; +import {HttpClient} from "@angular/common/http"; import {TextResonse} from "../../_types/text-response"; import {environment} from "../../../environments/environment"; import {map, tap} from "rxjs/operators"; -import {of, switchMap} from "rxjs"; -import {Location} from "@angular/common"; +import {switchMap} from "rxjs"; +import {allPersonFilterFields, PersonFilterField} from "../../_models/metadata/v2/person-filter-field"; +import {allPersonSortFields} from "../../_models/metadata/v2/person-sort-field"; +import { + FilterSettingsBase, + PersonFilterSettings, + SeriesFilterSettings, + ValidFilterEntity +} from "../../metadata-filter/filter-settings"; +import {SortFieldPipe} from "../../_pipes/sort-field.pipe"; +import {GenericFilterFieldPipe} from "../../_pipes/generic-filter-field.pipe"; +import {TranslocoService} from "@jsverse/transloco"; @Injectable({ @@ -20,59 +29,64 @@ import {Location} from "@angular/common"; }) export class FilterUtilitiesService { - private readonly location = inject(Location); private readonly router = inject(Router); private readonly metadataService = inject(MetadataService); private readonly http = inject(HttpClient); + private readonly translocoService = inject(TranslocoService); - private apiUrl = environment.apiUrl; + private readonly sortFieldPipe = new SortFieldPipe(this.translocoService); + private readonly genericFilterFieldPipe = new GenericFilterFieldPipe(); - encodeFilter(filter: SeriesFilterV2 | undefined) { + private readonly apiUrl = environment.apiUrl; + + encodeFilter(filter: FilterV2 | undefined) { return this.http.post(this.apiUrl + 'filter/encode', filter, TextResonse); } decodeFilter(encodedFilter: string) { - return this.http.post(this.apiUrl + 'filter/decode', {encodedFilter}).pipe(map(filter => { + return this.http.post(this.apiUrl + 'filter/decode', {encodedFilter}).pipe(map(filter => { if (filter == null) { - filter = this.metadataService.createDefaultFilterDto(); - filter.statements.push(this.createSeriesV2DefaultStatement()); + filter = this.metadataService.createDefaultFilterDto('series'); + filter.statements.push(this.metadataService.createDefaultFilterStatement('series')); } return filter; })) } - updateUrlFromFilter(filter: SeriesFilterV2 | undefined) { + /** + * Encodes the filter and patches into the url + * @param filter + */ + updateUrlFromFilter(filter: FilterV2 | undefined) { return this.encodeFilter(filter).pipe(tap(encodedFilter => { window.history.replaceState(window.location.href, '', window.location.href.split('?')[0]+ '?' + encodedFilter); })); } - filterPresetsFromUrl(snapshot: ActivatedRouteSnapshot) { - const filter = this.metadataService.createDefaultFilterDto(); - filter.statements.push(this.createSeriesV2DefaultStatement()); - if (!window.location.href.includes('?')) return of(filter); - - return this.decodeFilter(window.location.href.split('?')[1]); - } - /** - * Applies and redirects to the passed page with the filter encoded + * Applies and redirects to the passed page with the filter encoded (Series only) * @param page * @param filter * @param comparison * @param value */ applyFilter(page: Array, filter: FilterField, comparison: FilterComparison, value: string) { - const dto = this.createSeriesV2Filter(); - dto.statements.push(this.metadataService.createDefaultFilterStatement(filter, comparison, value + '')); + const dto = this.metadataService.createDefaultFilterDto('series'); + dto.statements.push(this.metadataService.createFilterStatement(filter, comparison, value + '')); return this.encodeFilter(dto).pipe(switchMap(encodedFilter => { return this.router.navigateByUrl(page.join('/') + '?' + encodedFilter); })); } - applyFilterWithParams(page: Array, filter: SeriesFilterV2, extraParams: Params) { + /** + * (Series only) + * @param page + * @param filter + * @param extraParams + */ + applyFilterWithParams(page: Array, filter: FilterV2, extraParams: Params) { return this.encodeFilter(filter).pipe(switchMap(encodedFilter => { let url = page.join('/') + '?' + encodedFilter; url += Object.keys(extraParams).map(k => `&${k}=${extraParams[k]}`).join(''); @@ -81,23 +95,228 @@ export class FilterUtilitiesService { })); } - createSeriesV2Filter(): SeriesFilterV2 { - return { - combination: FilterCombination.And, - statements: [], - limitTo: 0, - sortOptions: { - isAscending: true, - sortField: SortField.SortName - }, - }; + + createPersonV2Filter(): FilterV2 { + return { + combination: FilterCombination.And, + statements: [], + limitTo: 0, + sortOptions: { + isAscending: true, + sortField: SortField.SortName + }, + }; } - createSeriesV2DefaultStatement(): FilterStatement { - return { - comparison: FilterComparison.Equal, - value: '', - field: FilterField.SeriesName - } + /** + * Returns the Sort Fields for the Metadata filter based on the entity. + * @param type + */ + getSortFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return allSeriesSortFields.map(f => { + return {title: this.sortFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + case 'person': + return allPersonSortFields.map(f => { + return {title: this.sortFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + default: + return [] as {title: string, value: T}[]; + } + } + + /** + * Returns the Filter Fields for the Metadata filter based on the entity. + * @param type + */ + getFilterFields(type: ValidFilterEntity): {title: string, value: T}[] { + switch (type) { + case 'series': + return allSeriesFilterFields.map(f => { + return {title: this.genericFilterFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + case 'person': + return allPersonFilterFields.map(f => { + return {title: this.genericFilterFieldPipe.transform(f, type), value: f}; + }).sort((a, b) => a.title.localeCompare(b.title)) as unknown as {title: string, value: T}[]; + default: + return [] as {title: string, value: T}[]; + } + } + + /** + * Returns the default field for the Series or Person entity aka what should be there if there are no statements + * @param type + */ + getDefaultFilterField(type: ValidFilterEntity) { + switch (type) { + case 'series': + return FilterField.SeriesName as unknown as T; + case 'person': + return PersonFilterField.Role as unknown as T; + } + } + + /** + * Returns the appropriate Dropdown Fields based on the entity type + * @param type + */ + getDropdownFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.PublicationStatus, FilterField.Languages, FilterField.AgeRating, + FilterField.Translators, FilterField.Characters, FilterField.Publisher, + FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, + FilterField.Colorist, FilterField.Inker, FilterField.Penciller, + FilterField.Writers, FilterField.Genres, FilterField.Libraries, + FilterField.Formats, FilterField.CollectionTags, FilterField.Tags, + FilterField.Imprint, FilterField.Team, FilterField.Location + ] as unknown as T[]; + case 'person': + return [ + PersonFilterField.Role + ] as unknown as T[]; + } + } + + /** + * Returns the applicable String fields + * @param type + */ + getStringFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath, PersonFilterField.Name + ] as unknown as T[]; + case 'person': + return [ + PersonFilterField.Name + ] as unknown as T[]; + } + } + + getNumberFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.ReadTime, FilterField.ReleaseYear, FilterField.ReadProgress, + FilterField.UserRating, FilterField.AverageRating, FilterField.ReadLast + ] as unknown as T[]; + case 'person': + return [ + PersonFilterField.ChapterCount, PersonFilterField.SeriesCount + ] as unknown as T[]; + } + } + + getBooleanFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.WantToRead + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDateFields(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.ReadingDate + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getNumberFieldsThatIncludeDateComparisons(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.ReleaseYear + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDropdownFieldsThatIncludeDateComparisons(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.AgeRating + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDropdownFieldsWithoutMustContains(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.Libraries, FilterField.Formats, FilterField.AgeRating, FilterField.PublicationStatus + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getDropdownFieldsThatIncludeNumberComparisons(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.AgeRating + ] as unknown as T[]; + case 'person': + return [ + + ] as unknown as T[]; + } + } + + getFieldsThatShouldIncludeIsEmpty(type: ValidFilterEntity) { + switch (type) { + case 'series': + return [ + FilterField.Summary, FilterField.UserRating, FilterField.Genres, + FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear, + FilterField.Translators, FilterField.Characters, FilterField.Publisher, + FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer, + FilterField.Colorist, FilterField.Inker, FilterField.Penciller, + FilterField.Writers, FilterField.Imprint, FilterField.Team, + FilterField.Location + ] as unknown as T[]; + case 'person': + return [] as unknown as T[]; + } + } + + getDefaultSettings(entityType: ValidFilterEntity | "other" | undefined): FilterSettingsBase { + if (entityType === 'other' || entityType === undefined) { + // It doesn't matter, return series type + return new SeriesFilterSettings(); + } + + if (entityType == 'series') return new SeriesFilterSettings(); + if (entityType == 'person') return new PersonFilterSettings(); + + return new SeriesFilterSettings(); } } diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index afb63ab1d..da90ca412 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -1,5 +1,5 @@ import {HttpParams} from '@angular/common/http'; -import {Injectable} from '@angular/core'; +import {Inject, Injectable, signal, Signal} from '@angular/core'; import {Chapter} from 'src/app/_models/chapter'; import {LibraryType} from 'src/app/_models/library/library'; import {MangaFormat} from 'src/app/_models/manga-format'; @@ -8,6 +8,8 @@ import {Series} from 'src/app/_models/series'; import {Volume} from 'src/app/_models/volume'; import {translate} from "@jsverse/transloco"; import {debounceTime, ReplaySubject, shareReplay} from "rxjs"; +import {DOCUMENT} from "@angular/common"; +import getComputedStyle from "@popperjs/core/lib/dom-utils/getComputedStyle"; export enum KEY_CODES { RIGHT_ARROW = 'ArrowRight', @@ -27,12 +29,37 @@ export enum KEY_CODES { SHIFT = 'Shift' } +/** + * Use {@link UserBreakpoint} and {@link UtilityService.activeUserBreakpoint} for breakpoint that should depend on user settings + */ export enum Breakpoint { Mobile = 768, Tablet = 1280, Desktop = 1440 } +/* +Breakpoints, but they're derived from css vars in the theme + */ +export enum UserBreakpoint { + /** + * This is to be used in the UI/as value to disable the functionality with breakpoint, will not actually be set as a breakpoint + */ + Never = 0, + /** + * --mobile-breakpoint + */ + Mobile = 1, + /** + * --tablet-breakpoint + */ + Tablet = 2, + /** + * --desktop-breakpoint, does not actually matter as everything that's not mobile or tablet will be desktop + */ + Desktop = 3, +} + @Injectable({ providedIn: 'root' @@ -42,11 +69,19 @@ export class UtilityService { public readonly activeBreakpointSource = new ReplaySubject(1); public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true})); + /** + * The currently active breakpoint, is {@link UserBreakpoint.Never} until the app has loaded + */ + public readonly activeUserBreakpoint = signal(UserBreakpoint.Never); + // TODO: I need an isPhone/Tablet so that I can easily trigger different views mangaFormatKeys: string[] = []; + constructor(@Inject(DOCUMENT) private document: Document) { + } + sortChapters = (a: Chapter, b: Chapter) => { return a.minNumber - b.minNumber; @@ -132,6 +167,34 @@ export class UtilityService { return Breakpoint.Desktop; } + updateUserBreakpoint(): void { + this.activeUserBreakpoint.set(this.getActiveUserBreakpoint()); + } + + private getActiveUserBreakpoint(): UserBreakpoint { + const style = getComputedStyle(this.document.body) + const mobileBreakPoint = this.parseOrDefault(style.getPropertyValue('--setting-mobile-breakpoint'), Breakpoint.Mobile); + const tabletBreakPoint = this.parseOrDefault(style.getPropertyValue('--setting-tablet-breakpoint'), Breakpoint.Tablet); + //const desktopBreakPoint = this.parseOrDefault(style.getPropertyValue('--setting-desktop-breakpoint'), Breakpoint.Desktop); + + if (window.innerWidth <= mobileBreakPoint) { + return UserBreakpoint.Mobile; + } else if (window.innerWidth <= tabletBreakPoint) { + return UserBreakpoint.Tablet; + } + + // Fallback to desktop + return UserBreakpoint.Desktop; + } + + private parseOrDefault(s: string, def: T): T { + const ret = parseInt(s, 10); + if (isNaN(ret)) { + return def; + } + return ret as T; + } + isInViewport(element: Element, additionalTopOffset: number = 0) { const rect = element.getBoundingClientRect(); return ( diff --git a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts index 481c9b48c..7cfd257e2 100644 --- a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts +++ b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts @@ -1,7 +1,7 @@ -import { ConfirmButton } from './confirm-button'; +import {ConfirmButton} from './confirm-button'; export class ConfirmConfig { - _type: 'confirm' | 'alert' | 'info' = 'confirm'; + _type: 'confirm' | 'alert' | 'info' | 'prompt' = 'confirm'; header: string = 'Confirm'; content: string = ''; buttons: Array = []; diff --git a/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html b/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html index 21b741cd3..213c80ceb 100644 --- a/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html +++ b/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html @@ -5,8 +5,18 @@ }
    - + + @if (config._type === 'prompt') { + + } @else { + + } +