diff --git a/API.Tests/Services/ReadingProfileServiceTest.cs b/API.Tests/Services/ReadingProfileServiceTest.cs new file mode 100644 index 000000000..b7f12bc00 --- /dev/null +++ b/API.Tests/Services/ReadingProfileServiceTest.cs @@ -0,0 +1,174 @@ +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 Kavita.Common; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace API.Tests.Services; + +public class ReadingProfileServiceTest: AbstractDbTest +{ + + public async Task<(IReadingProfileService, 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); + user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.UserPreferences); + + return (rps, user, library, series); + } + + [Fact] + public async Task DeleteImplicitSeriesReadingProfile() + { + await ResetDb(); + var (rps, user, library, series) = await Setup(); + + var series2 = new SeriesBuilder("Rainbows After Storms").Build(); + library.Series.Add(series2); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithImplicit(true) + .WithSeries(series) + .Build(); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series2) + .Build(); + + UnitOfWork.AppUserReadingProfileRepository.Add(profile); + UnitOfWork.AppUserReadingProfileRepository.Add(profile2); + + await UnitOfWork.CommitAsync(); + + await rps.DeleteImplicitForSeries(user.Id, series.Id); + await rps.DeleteImplicitForSeries(user.Id, series2.Id); + + profile = await UnitOfWork.AppUserReadingProfileRepository.GetProfile(profile.Id); + Assert.NotNull(profile); + Assert.Empty(profile.Series); + + profile2 = await UnitOfWork.AppUserReadingProfileRepository.GetProfile(profile2.Id); + Assert.NotNull(profile2); + Assert.NotEmpty(profile2.Series); + } + + [Fact] + public async Task CantDeleteDefaultReadingProfile() + { + await ResetDb(); + var (rps, user, _, _) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id).Build(); + Context.AppUserReadingProfile.Add(profile); + await UnitOfWork.CommitAsync(); + + user.UserPreferences.DefaultReadingProfileId = profile.Id; + await UnitOfWork.CommitAsync(); + + await Assert.ThrowsAsync(async () => + { + await rps.DeleteReadingProfile(user.Id, profile.Id); + }); + + var profile2 = new AppUserReadingProfileBuilder(user.Id).Build(); + Context.AppUserReadingProfile.Add(profile2); + await UnitOfWork.CommitAsync(); + + await rps.DeleteReadingProfile(user.Id, profile2.Id); + await UnitOfWork.CommitAsync(); + + var allProfiles = await Context.AppUserReadingProfile.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.UpdateReadingProfileForSeries(user.Id, series.Id, dto); + + var profile = await UnitOfWork.AppUserReadingProfileRepository.GetProfileForSeries(user.Id, series.Id); + Assert.NotNull(profile); + Assert.Contains(profile.Series, s => s.Id == series.Id); + Assert.True(profile.Implicit); + } + + [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) + .WithName("Global") + .Build(); + Context.AppUserReadingProfile.Add(profile); + Context.AppUserReadingProfile.Add(profile2); + Context.AppUserReadingProfile.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").WithLibraryId(lib2.Id).Build(); + + user.Libraries.Add(lib2); + await UnitOfWork.CommitAsync(); + + user.UserPreferences.DefaultReadingProfileId = profile3.Id; + await UnitOfWork.CommitAsync(); + + var p = await rps.GetReadingProfileForSeries(user.Id, series); + Assert.NotNull(p); + Assert.Equal("Series Specific", p.Name); + + p = await rps.GetReadingProfileForSeries(user.Id, series2); + Assert.NotNull(p); + Assert.Equal("Library Specific", p.Name); + + p = await rps.GetReadingProfileForSeries(user.Id, series3); + Assert.NotNull(p); + Assert.Equal("Global", p.Name); + } + + protected override async Task ResetDb() + { + Context.AppUserReadingProfile.RemoveRange(Context.AppUserReadingProfile); + await UnitOfWork.CommitAsync(); + } +} diff --git a/API/DTOs/UserReadingProfileDto.cs b/API/DTOs/UserReadingProfileDto.cs index 0bce56295..1c5891a0d 100644 --- a/API/DTOs/UserReadingProfileDto.cs +++ b/API/DTOs/UserReadingProfileDto.cs @@ -12,6 +12,8 @@ public sealed record UserReadingProfileDto public int UserId { get; init; } + public string Name { get; init; } + /// public bool Implicit { get; set; } = false; @@ -61,6 +63,9 @@ public sealed record UserReadingProfileDto [Required] public bool AllowAutomaticWebtoonReaderDetection { get; set; } + /// + public int? WidthOverride { get; set; } + #endregion #region EpubReader diff --git a/API/Data/Migrations/20250514215429_AppUserReadingProfiles.Designer.cs b/API/Data/Migrations/20250515215234_AppUserReadingProfiles.Designer.cs similarity index 99% rename from API/Data/Migrations/20250514215429_AppUserReadingProfiles.Designer.cs rename to API/Data/Migrations/20250515215234_AppUserReadingProfiles.Designer.cs index 132a99a98..873394173 100644 --- a/API/Data/Migrations/20250514215429_AppUserReadingProfiles.Designer.cs +++ b/API/Data/Migrations/20250515215234_AppUserReadingProfiles.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace API.Data.Migrations { [DbContext(typeof(DataContext))] - [Migration("20250514215429_AppUserReadingProfiles")] + [Migration("20250515215234_AppUserReadingProfiles")] partial class AppUserReadingProfiles { /// @@ -672,6 +672,12 @@ namespace API.Data.Migrations b.Property("LayoutMode") .HasColumnType("INTEGER"); + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + b.Property("PageSplitOption") .HasColumnType("INTEGER"); diff --git a/API/Data/Migrations/20250514215429_AppUserReadingProfiles.cs b/API/Data/Migrations/20250515215234_AppUserReadingProfiles.cs similarity index 97% rename from API/Data/Migrations/20250514215429_AppUserReadingProfiles.cs rename to API/Data/Migrations/20250515215234_AppUserReadingProfiles.cs index 7a3d1e6c4..77a2071ab 100644 --- a/API/Data/Migrations/20250514215429_AppUserReadingProfiles.cs +++ b/API/Data/Migrations/20250515215234_AppUserReadingProfiles.cs @@ -23,6 +23,8 @@ namespace API.Data.Migrations { 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), UserId = table.Column(type: "INTEGER", nullable: false), ReadingDirection = table.Column(type: "INTEGER", nullable: false), ScalingOption = table.Column(type: "INTEGER", nullable: false), diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 0bd983e56..505f37c53 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -669,6 +669,12 @@ namespace API.Data.Migrations b.Property("LayoutMode") .HasColumnType("INTEGER"); + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + b.Property("PageSplitOption") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/AppUserReadingProfileRepository.cs b/API/Data/Repositories/AppUserReadingProfileRepository.cs index 8dc5a61eb..0d29404b5 100644 --- a/API/Data/Repositories/AppUserReadingProfileRepository.cs +++ b/API/Data/Repositories/AppUserReadingProfileRepository.cs @@ -1,19 +1,29 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Extensions.QueryExtensions; using AutoMapper; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +[Flags] +public enum ReadingProfileIncludes +{ + None = 0, + Series = 1 << 1, + Library = 1 << 2 +} + public interface IAppUserReadingProfileRepository { Task> GetProfilesForUser(int userId); Task GetProfileForSeries(int userId, int seriesId); Task GetProfileForLibrary(int userId, int libraryId); - Task GetProfile(int profileId); + Task GetProfile(int profileId, ReadingProfileIncludes includes = ReadingProfileIncludes.None); void Add(AppUserReadingProfile readingProfile); void Update(AppUserReadingProfile readingProfile); @@ -44,10 +54,11 @@ public class AppUserReadingProfileRepository(DataContext context, IMapper mapper .FirstOrDefaultAsync(); } - public async Task GetProfile(int profileId) + public async Task GetProfile(int profileId, ReadingProfileIncludes includes = ReadingProfileIncludes.None) { return await context.AppUserReadingProfile .Where(rp => rp.Id == profileId) + .Includes(includes) .FirstOrDefaultAsync(); } diff --git a/API/Entities/AppUserReadingProfile.cs b/API/Entities/AppUserReadingProfile.cs index dcad9e826..1c148bf22 100644 --- a/API/Entities/AppUserReadingProfile.cs +++ b/API/Entities/AppUserReadingProfile.cs @@ -8,6 +8,9 @@ public class AppUserReadingProfile { public int Id { get; set; } + public string Name { get; set; } + public string NormalizedName { get; set; } + public int UserId { get; set; } public AppUser User { get; set; } diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index bfc585455..8bbbc8f4b 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -342,4 +342,20 @@ public static class IncludesExtensions return queryable; } + + public static IQueryable Includes(this IQueryable queryable, ReadingProfileIncludes includeFlags) + { + + if (includeFlags.HasFlag(ReadingProfileIncludes.Series)) + { + queryable = queryable.Include(r => r.Series); + } + + if (includeFlags.HasFlag(ReadingProfileIncludes.Library)) + { + queryable = queryable.Include(r => r.Libraries); + } + + return queryable; + } } diff --git a/API/Helpers/Builders/AppUserReadingProfileBuilder.cs b/API/Helpers/Builders/AppUserReadingProfileBuilder.cs new file mode 100644 index 000000000..166eaec24 --- /dev/null +++ b/API/Helpers/Builders/AppUserReadingProfileBuilder.cs @@ -0,0 +1,48 @@ +using API.Entities; +using API.Extensions; + +namespace API.Helpers.Builders; + +public class AppUserReadingProfileBuilder +{ + private readonly AppUserReadingProfile _profile; + + public AppUserReadingProfile Build() => _profile; + + public AppUserReadingProfileBuilder(int userId) + { + _profile = new AppUserReadingProfile + { + UserId = userId, + Series = [], + Libraries = [], + }; + } + + public AppUserReadingProfileBuilder WithSeries(Series series) + { + _profile.Series.Add(series); + return this; + } + + public AppUserReadingProfileBuilder WithLibrary(Library library) + { + _profile.Libraries.Add(library); + return this; + } + + public AppUserReadingProfileBuilder WithImplicit(bool b) + { + _profile.Implicit = b; + return this; + } + + public AppUserReadingProfileBuilder WithName(string name) + { + _profile.Name = name; + _profile.NormalizedName = name.ToNormalized(); + return this; + } + + +} diff --git a/API/Services/ReadingProfileService.cs b/API/Services/ReadingProfileService.cs index 24e22f7a3..55fe6740c 100644 --- a/API/Services/ReadingProfileService.cs +++ b/API/Services/ReadingProfileService.cs @@ -1,9 +1,14 @@ 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.Extensions; +using API.Helpers.Builders; +using Kavita.Common; namespace API.Services; @@ -45,6 +50,16 @@ public interface IReadingProfileService /// Task DeleteImplicitForSeries(int userId, int seriesId); + /// + /// Deletes a given profile for a user + /// + /// + /// + /// + /// + /// The default profile for the user cannot be deleted + Task DeleteReadingProfile(int userId, int profileId); + } public class ReadingProfileService(IUnitOfWork unitOfWork): IReadingProfileService @@ -69,10 +84,7 @@ public class ReadingProfileService(IUnitOfWork unitOfWork): IReadingProfileServi var existingProfile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(dto.Id); if (existingProfile == null) { - existingProfile = new AppUserReadingProfile - { - UserId = userId, - }; + existingProfile = new AppUserReadingProfileBuilder(userId).Build(); } if (existingProfile.UserId != userId) return false; @@ -86,19 +98,34 @@ public class ReadingProfileService(IUnitOfWork unitOfWork): IReadingProfileServi { var existingProfile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(dto.Id); + // TODO: Rewrite + var isNew = false; if (existingProfile == null || existingProfile.Series.All(s => s.Id != seriesId)) { - existingProfile = new AppUserReadingProfile - { - Implicit = true, - UserId = userId, - }; + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null) throw new KeyNotFoundException(); + + existingProfile = new AppUserReadingProfileBuilder(userId) + .WithSeries(series) + .WithImplicit(true) + .Build(); + + isNew = true; } if (existingProfile.UserId != userId) return false; UpdateReaderProfileFields(existingProfile, dto); - unitOfWork.AppUserReadingProfileRepository.Update(existingProfile); + + if (isNew) + { + unitOfWork.AppUserReadingProfileRepository.Add(existingProfile); + } + else + { + unitOfWork.AppUserReadingProfileRepository.Update(existingProfile); + } + return await unitOfWork.CommitAsync(); } @@ -109,12 +136,33 @@ public class ReadingProfileService(IUnitOfWork unitOfWork): IReadingProfileServi if (!profile.Implicit) return; + profile.Series = profile.Series.Where(s => s.Id != seriesId).ToList(); + + await unitOfWork.CommitAsync(); + } + + public async Task DeleteReadingProfile(int userId, int profileId) + { + var profile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(profileId); + if (profile == null) throw new KeyNotFoundException(); + + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || profile.UserId != userId) throw new UnauthorizedAccessException(); + + if (user.UserPreferences.DefaultReadingProfileId == profileId) throw new KavitaException("cant-delete-default-profile"); + unitOfWork.AppUserReadingProfileRepository.Remove(profile); await unitOfWork.CommitAsync(); } private static void UpdateReaderProfileFields(AppUserReadingProfile existingProfile, UserReadingProfileDto dto) { + if (!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;