Bulk actions and nicer behaviour with implicit profiles
This commit is contained in:
parent
9b4a4b8a50
commit
483c90904d
18 changed files with 481 additions and 113 deletions
|
|
@ -1,3 +1,4 @@
|
|||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
|
|
@ -65,7 +66,11 @@ public class ReadingProfileServiceTest: AbstractDbTest
|
|||
Assert.NotNull(seriesProfileDto);
|
||||
Assert.Equal("Implicit Profile", seriesProfileDto.Name);
|
||||
|
||||
await rps.DeleteImplicitForSeries(user.Id, series.Id);
|
||||
await rps.UpdateReadingProfile(user.Id, new UserReadingProfileDto
|
||||
{
|
||||
Id = profile2.Id,
|
||||
WidthOverride = 23,
|
||||
});
|
||||
|
||||
seriesProfile = await UnitOfWork.AppUserReadingProfileRepository.GetProfileForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
|
|
@ -76,41 +81,6 @@ public class ReadingProfileServiceTest: AbstractDbTest
|
|||
Assert.Equal("Non-implicit Profile", seriesProfileDto.Name);
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
|
|
@ -257,6 +227,135 @@ public class ReadingProfileServiceTest: AbstractDbTest
|
|||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchAddReadingProfiles()
|
||||
{
|
||||
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.AppUserReadingProfile.Add(profile);
|
||||
|
||||
var profile2 = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithSeries(series)
|
||||
.WithName("Profile2")
|
||||
.Build();
|
||||
Context.AppUserReadingProfile.Add(profile2);
|
||||
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var someSeriesIds = lib.Series.Take(lib.Series.Count / 2).Select(s => s.Id).ToList();
|
||||
await rps.BatchAddProfileToSeries(user.Id, profile.Id, someSeriesIds);
|
||||
|
||||
foreach (var id in someSeriesIds)
|
||||
{
|
||||
var foundProfile = await rps.GetReadingProfileForSeries(user.Id, id);
|
||||
Assert.NotNull(foundProfile);
|
||||
Assert.Equal(profile.Id, foundProfile.Id);
|
||||
}
|
||||
|
||||
var allIds = lib.Series.Select(s => s.Id).ToList();
|
||||
await rps.BatchAddProfileToSeries(user.Id, profile2.Id, allIds);
|
||||
|
||||
foreach (var id in allIds)
|
||||
{
|
||||
var foundProfile = await rps.GetReadingProfileForSeries(user.Id, id);
|
||||
Assert.NotNull(foundProfile);
|
||||
Assert.Equal(profile2.Id, foundProfile.Id);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDeletesImplicit()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, series) = await Setup();
|
||||
|
||||
var implicitProfile = Mapper.Map<UserReadingProfileDto>(new AppUserReadingProfileBuilder(user.Id)
|
||||
.Build());
|
||||
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("Profile 1")
|
||||
.Build();
|
||||
Context.AppUserReadingProfile.Add(profile);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await rps.AddProfileToSeries(user.Id, profile.Id, series.Id);
|
||||
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, implicitProfile);
|
||||
|
||||
|
||||
var seriesProfile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.True(seriesProfile.Implicit);
|
||||
|
||||
var profileDto = Mapper.Map<UserReadingProfileDto>(profile);
|
||||
await rps.UpdateReadingProfile(user.Id, profileDto);
|
||||
|
||||
seriesProfile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.False(seriesProfile.Implicit);
|
||||
|
||||
var implicitCount = await Context.AppUserReadingProfile
|
||||
.Where(p => p.Implicit).CountAsync();
|
||||
Assert.Equal(0, implicitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchUpdateDeletesImplicit()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, series) = await Setup();
|
||||
|
||||
var implicitProfile = Mapper.Map<UserReadingProfileDto>(new AppUserReadingProfileBuilder(user.Id)
|
||||
.Build());
|
||||
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("Profile 1")
|
||||
.Build();
|
||||
Context.AppUserReadingProfile.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.GetReadingProfileForSeries(user.Id, id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.True(seriesProfile.Implicit);
|
||||
}
|
||||
|
||||
await rps.BatchAddProfileToSeries(user.Id, profile.Id, ids);
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var seriesProfile = await rps.GetReadingProfileForSeries(user.Id, id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.False(seriesProfile.Implicit);
|
||||
}
|
||||
|
||||
var implicitCount = await Context.AppUserReadingProfile
|
||||
.Where(p => p.Implicit).CountAsync();
|
||||
Assert.Equal(0, implicitCount);
|
||||
}
|
||||
|
||||
protected override async Task ResetDb()
|
||||
{
|
||||
Context.AppUserReadingProfile.RemoveRange(Context.AppUserReadingProfile);
|
||||
|
|
|
|||
|
|
@ -49,26 +49,15 @@ public class ReadingProfileController(ILogger<ReadingProfileController> logger,
|
|||
/// Updates the given reading profile, must belong to the current user
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <param name="seriesCtx">
|
||||
/// Optionally, from which series the update is called.
|
||||
/// If set, will delete the implicit reading profile if it exists
|
||||
/// </param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>This does not update connected series, and libraries. Use
|
||||
/// <see cref="DeleteProfileFromSeries"/>, <see cref="AddProfileToSeries"/>,
|
||||
/// <see cref="DeleteProfileFromLibrary"/>, <see cref="AddProfileToLibrary"/>
|
||||
/// <remarks>
|
||||
/// This does not update connected series, and libraries.
|
||||
/// Deletes all implicit profiles for series linked to this profile
|
||||
/// </remarks>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> UpdateReadingProfile([FromBody] UserReadingProfileDto dto, [FromQuery] int? seriesCtx)
|
||||
public async Task<ActionResult> UpdateReadingProfile([FromBody] UserReadingProfileDto dto)
|
||||
{
|
||||
if (seriesCtx.HasValue)
|
||||
{
|
||||
await readingProfileService.DeleteImplicitForSeries(User.GetUserId(), seriesCtx.Value);
|
||||
}
|
||||
|
||||
var success = await readingProfileService.UpdateReadingProfile(User.GetUserId(), dto);
|
||||
if (!success) return BadRequest();
|
||||
|
||||
await readingProfileService.UpdateReadingProfile(User.GetUserId(), dto);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
|
@ -92,9 +81,7 @@ public class ReadingProfileController(ILogger<ReadingProfileController> logger,
|
|||
[HttpPost("series")]
|
||||
public async Task<ActionResult> UpdateReadingProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId)
|
||||
{
|
||||
var success = await readingProfileService.UpdateImplicitReadingProfile(User.GetUserId(), seriesId, dto);
|
||||
if (!success) return BadRequest();
|
||||
|
||||
await readingProfileService.UpdateImplicitReadingProfile(User.GetUserId(), seriesId, dto);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
|
@ -178,4 +165,17 @@ public class ReadingProfileController(ILogger<ReadingProfileController> logger,
|
|||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns the reading profile to all passes series, and deletes their implicit profiles
|
||||
/// </summary>
|
||||
/// <param name="profileId"></param>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("batch")]
|
||||
public async Task<IActionResult> BatchAddReadingProfile([FromQuery] int profileId, [FromBody] IList<int> seriesIds)
|
||||
{
|
||||
await readingProfileService.BatchAddProfileToSeries(User.GetUserId(), profileId, seriesIds);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public interface IAppUserReadingProfileRepository
|
|||
Task<IList<AppUserReadingProfile>> GetProfilesForUser(int userId, bool nonImplicitOnly, ReadingProfileIncludes includes = ReadingProfileIncludes.None);
|
||||
Task<IList<UserReadingProfileDto>> GetProfilesDtoForUser(int userId, bool nonImplicitOnly, ReadingProfileIncludes includes = ReadingProfileIncludes.None);
|
||||
Task<AppUserReadingProfile?> GetProfileForSeries(int userId, int seriesId, ReadingProfileIncludes includes = ReadingProfileIncludes.None);
|
||||
Task<IList<AppUserReadingProfile>> GetProfilesForSeries(int userId, IList<int> seriesIds, bool implicitOnly, ReadingProfileIncludes includes = ReadingProfileIncludes.None);
|
||||
Task<UserReadingProfileDto?> GetProfileDtoForSeries(int userId, int seriesId);
|
||||
Task<AppUserReadingProfile?> GetProfileForLibrary(int userId, int libraryId, ReadingProfileIncludes includes = ReadingProfileIncludes.None);
|
||||
Task<UserReadingProfileDto?> GetProfileDtoForLibrary(int userId, int libraryId);
|
||||
|
|
@ -33,11 +34,16 @@ public interface IAppUserReadingProfileRepository
|
|||
Task<UserReadingProfileDto?> GetProfileDto(int profileId);
|
||||
Task<AppUserReadingProfile?> GetProfileByName(int userId, string name);
|
||||
Task<SeriesReadingProfile?> GetSeriesProfile(int userId, int seriesId);
|
||||
Task<IList<SeriesReadingProfile>> GetSeriesProfilesForSeries(int userId, IList<int> seriesIds);
|
||||
Task<LibraryReadingProfile?> GetLibraryProfile(int userId, int libraryId);
|
||||
|
||||
void Add(AppUserReadingProfile readingProfile);
|
||||
void Add(SeriesReadingProfile readingProfile);
|
||||
void Update(AppUserReadingProfile readingProfile);
|
||||
void Update(SeriesReadingProfile readingProfile);
|
||||
void Remove(AppUserReadingProfile readingProfile);
|
||||
void Remove(SeriesReadingProfile readingProfile);
|
||||
void RemoveRange(IEnumerable<AppUserReadingProfile> readingProfiles);
|
||||
}
|
||||
|
||||
public class AppUserReadingProfileRepository(DataContext context, IMapper mapper): IAppUserReadingProfileRepository
|
||||
|
|
@ -70,6 +76,17 @@ public class AppUserReadingProfileRepository(DataContext context, IMapper mapper
|
|||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<AppUserReadingProfile>> GetProfilesForSeries(int userId, IList<int> seriesIds, bool implicitOnly, ReadingProfileIncludes includes = ReadingProfileIncludes.None)
|
||||
{
|
||||
return await context.AppUserReadingProfile
|
||||
.Where(rp
|
||||
=> rp.UserId == userId
|
||||
&& rp.Series.Any(s => seriesIds.Contains(s.SeriesId))
|
||||
&& (!implicitOnly || rp.Implicit))
|
||||
.Includes(includes)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<UserReadingProfileDto?> GetProfileDtoForSeries(int userId, int seriesId)
|
||||
{
|
||||
return await context.AppUserReadingProfile
|
||||
|
|
@ -126,6 +143,13 @@ public class AppUserReadingProfileRepository(DataContext context, IMapper mapper
|
|||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<SeriesReadingProfile>> GetSeriesProfilesForSeries(int userId, IList<int> seriesIds)
|
||||
{
|
||||
return await context.SeriesReadingProfile
|
||||
.Where(rp => seriesIds.Contains(rp.SeriesId) && rp.AppUserId == userId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<LibraryReadingProfile?> GetLibraryProfile(int userId, int libraryId)
|
||||
{
|
||||
return await context.LibraryReadingProfile
|
||||
|
|
@ -138,13 +162,33 @@ public class AppUserReadingProfileRepository(DataContext context, IMapper mapper
|
|||
context.AppUserReadingProfile.Add(readingProfile);
|
||||
}
|
||||
|
||||
public void Add(SeriesReadingProfile readingProfile)
|
||||
{
|
||||
context.SeriesReadingProfile.Add(readingProfile);
|
||||
}
|
||||
|
||||
public void Update(AppUserReadingProfile readingProfile)
|
||||
{
|
||||
context.AppUserReadingProfile.Update(readingProfile).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Update(SeriesReadingProfile readingProfile)
|
||||
{
|
||||
context.SeriesReadingProfile.Update(readingProfile).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Remove(AppUserReadingProfile readingProfile)
|
||||
{
|
||||
context.AppUserReadingProfile.Remove(readingProfile);
|
||||
}
|
||||
|
||||
public void Remove(SeriesReadingProfile readingProfile)
|
||||
{
|
||||
context.SeriesReadingProfile.Remove(readingProfile);
|
||||
}
|
||||
|
||||
public void RemoveRange(IEnumerable<AppUserReadingProfile> readingProfiles)
|
||||
{
|
||||
context.AppUserReadingProfile.RemoveRange(readingProfiles);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ public interface IReadingProfileService
|
|||
{
|
||||
/// <summary>
|
||||
/// Returns the ReadingProfile that should be applied to the given series, walks up the tree.
|
||||
/// Series -> Library -> Default
|
||||
/// Series (implicit) -> Series (Assigned) -> Library -> Default
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
|
|
@ -24,13 +24,13 @@ public interface IReadingProfileService
|
|||
Task<UserReadingProfileDto> GetReadingProfileForSeries(int userId, int seriesId);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a given reading profile for a user
|
||||
/// Updates a given reading profile for a user, and deletes all implicit profiles
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>Does not update connected series and libraries</remarks>
|
||||
Task<bool> UpdateReadingProfile(int userId, UserReadingProfileDto dto);
|
||||
Task UpdateReadingProfile(int userId, UserReadingProfileDto dto);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new reading profile for a user. Name must be unique per user
|
||||
|
|
@ -47,15 +47,7 @@ public interface IReadingProfileService
|
|||
/// <param name="seriesId"></param>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the implicit reading profile for a given series, if it exists
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
Task DeleteImplicitForSeries(int userId, int seriesId);
|
||||
Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a given profile for a user
|
||||
|
|
@ -76,6 +68,7 @@ public interface IReadingProfileService
|
|||
Task SetDefaultReadingProfile(int userId, int profileId);
|
||||
|
||||
Task AddProfileToSeries(int userId, int profileId, int seriesId);
|
||||
Task BatchAddProfileToSeries(int userId, int profileId, IList<int> seriesIds);
|
||||
Task RemoveProfileFromSeries(int userId, int profileId, int seriesId);
|
||||
|
||||
Task AddProfileToLibrary(int userId, int profileId, int libraryId);
|
||||
|
|
@ -103,19 +96,25 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService
|
|||
return await unitOfWork.AppUserReadingProfileRepository.GetProfileDto(user.UserPreferences.DefaultReadingProfileId);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateReadingProfile(int userId, UserReadingProfileDto dto)
|
||||
public async Task UpdateReadingProfile(int userId, UserReadingProfileDto dto)
|
||||
{
|
||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
|
||||
if (user == null) throw new UnauthorizedAccessException();
|
||||
|
||||
var existingProfile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(dto.Id);
|
||||
if (existingProfile == null) throw new KavitaException("profile-does-not-exist");
|
||||
var profile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(dto.Id, ReadingProfileIncludes.Series);
|
||||
if (profile == null) throw new KavitaException("profile-does-not-exist");
|
||||
|
||||
if (existingProfile.UserId != userId) throw new UnauthorizedAccessException();
|
||||
if (profile.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
||||
UpdateReaderProfileFields(existingProfile, dto);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(existingProfile);
|
||||
return await unitOfWork.CommitAsync();
|
||||
UpdateReaderProfileFields(profile, dto);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(profile);
|
||||
|
||||
// Remove all implicit profiles for series using this profile
|
||||
var allLinkedSeries = profile.Series.Select(sp => sp.SeriesId).ToList();
|
||||
var implicitProfiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForSeries(userId, allLinkedSeries, true);
|
||||
unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task<UserReadingProfileDto> CreateReadingProfile(int userId, UserReadingProfileDto dto)
|
||||
|
|
@ -135,7 +134,7 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService
|
|||
return await unitOfWork.AppUserReadingProfileRepository.GetProfileDto(newProfile.Id);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto)
|
||||
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();
|
||||
|
|
@ -147,39 +146,29 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService
|
|||
{
|
||||
UpdateReaderProfileFields(existingProfile, dto, false);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(existingProfile);
|
||||
return await unitOfWork.CommitAsync();
|
||||
await unitOfWork.CommitAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId) ?? throw new KeyNotFoundException();
|
||||
existingProfile = new AppUserReadingProfileBuilder(userId)
|
||||
var newProfile = new AppUserReadingProfileBuilder(userId)
|
||||
.WithSeries(series)
|
||||
.WithImplicit(true)
|
||||
.Build();
|
||||
|
||||
UpdateReaderProfileFields(existingProfile, dto, false);
|
||||
existingProfile.Name = $"Implicit Profile for {seriesId}";
|
||||
existingProfile.NormalizedName = existingProfile.Name.ToNormalized();
|
||||
|
||||
user.UserPreferences.ReadingProfiles.Add(existingProfile);
|
||||
return await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteImplicitForSeries(int userId, int seriesId)
|
||||
{
|
||||
var profile = await unitOfWork.AppUserReadingProfileRepository.GetProfileForSeries(userId, seriesId, ReadingProfileIncludes.Series);
|
||||
if (profile == null) return;
|
||||
|
||||
if (!profile.Implicit) return;
|
||||
|
||||
profile.Series = profile.Series.Where(s => s.SeriesId != seriesId).ToList();
|
||||
// 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.UserPreferences.ReadingProfiles.Add(newProfile);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteReadingProfile(int userId, int profileId)
|
||||
{
|
||||
var profile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(profileId);
|
||||
if (profile == null) throw new KavitaException(await localizationService.Translate(userId, "profile-doesnt-exist"));
|
||||
if (profile == null) throw new KavitaException("profile-doesnt-exist");
|
||||
|
||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
|
||||
if (user == null || profile.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
|
@ -212,16 +201,56 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService
|
|||
if (profile.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
||||
var seriesProfile = await unitOfWork.AppUserReadingProfileRepository.GetSeriesProfile(userId, seriesId);
|
||||
if (seriesProfile == null)
|
||||
if (seriesProfile != null)
|
||||
{
|
||||
seriesProfile = new SeriesReadingProfile
|
||||
seriesProfile.ReadingProfile = profile;
|
||||
await unitOfWork.CommitAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
seriesProfile = new SeriesReadingProfile
|
||||
{
|
||||
AppUserId = userId,
|
||||
SeriesId = seriesId,
|
||||
ReadingProfileId = profile.Id
|
||||
};
|
||||
|
||||
unitOfWork.AppUserReadingProfileRepository.Add(seriesProfile);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task BatchAddProfileToSeries(int userId, int profileId, IList<int> seriesIds)
|
||||
{
|
||||
var profile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(profileId, ReadingProfileIncludes.Series);
|
||||
if (profile == null) throw new KavitaException("profile-not-found");
|
||||
|
||||
if (profile.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
||||
var seriesProfiles = await unitOfWork.AppUserReadingProfileRepository.GetSeriesProfilesForSeries(userId, seriesIds);
|
||||
var newSeriesIds = seriesIds.Except(seriesProfiles.Select(p => p.SeriesId)).ToList();
|
||||
|
||||
// Update existing
|
||||
foreach (var seriesProfile in seriesProfiles)
|
||||
{
|
||||
seriesProfile.ReadingProfile = profile;
|
||||
}
|
||||
|
||||
// Create new ones
|
||||
foreach (var seriesId in newSeriesIds)
|
||||
{
|
||||
var seriesProfile = new SeriesReadingProfile
|
||||
{
|
||||
AppUserId = userId,
|
||||
SeriesId = seriesId,
|
||||
ReadingProfile = profile,
|
||||
};
|
||||
unitOfWork.AppUserReadingProfileRepository.Add(seriesProfile);
|
||||
}
|
||||
|
||||
seriesProfile.ReadingProfile = profile;
|
||||
// Remove all implicit profiles
|
||||
var implicitProfiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForSeries(userId, seriesIds, true);
|
||||
unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
|
|
@ -288,6 +317,7 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService
|
|||
existingProfile.BackgroundColor = string.IsNullOrEmpty(dto.BackgroundColor) ? "#000000" : dto.BackgroundColor;
|
||||
existingProfile.SwipeToPaginate = dto.SwipeToPaginate;
|
||||
existingProfile.AllowAutomaticWebtoonReaderDetection = dto.AllowAutomaticWebtoonReaderDetection;
|
||||
existingProfile.WidthOverride = dto.WidthOverride;
|
||||
|
||||
// EpubReading
|
||||
existingProfile.BookReaderMargin = dto.BookReaderMargin;
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ export enum Action {
|
|||
* Merge two (or more?) entities
|
||||
*/
|
||||
Merge = 29,
|
||||
/**
|
||||
* Add to a reading profile
|
||||
*/
|
||||
AddToReadingProfile = 30,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -529,6 +533,16 @@ export class ActionFactoryService {
|
|||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.AddToReadingProfile,
|
||||
title: 'add-to-reading-profile',
|
||||
description: 'add-to-reading-profile-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
BulkAddToReadingProfileComponent
|
||||
} from "../cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component";
|
||||
|
||||
|
||||
export type LibraryActionCallback = (library: Partial<Library>) => void;
|
||||
|
|
@ -813,4 +816,30 @@ export class ActionService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds series to a reading list
|
||||
* @param series
|
||||
* @param callback
|
||||
*/
|
||||
addMultipleToReadingProfile(series: Array<Series>, callback?: BooleanActionCallback) {
|
||||
if (this.readingListModalRef != null) { return; }
|
||||
|
||||
this.readingListModalRef = this.modalService.open(BulkAddToReadingProfileComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
|
||||
this.readingListModalRef.componentInstance.seriesIds = series.map(s => s.id)
|
||||
this.readingListModalRef.componentInstance.title = "hi"
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ export class ReadingProfileService {
|
|||
return this.httpClient.get<ReadingProfile>(this.baseUrl + "ReadingProfile/"+seriesId);
|
||||
}
|
||||
|
||||
updateProfile(profile: ReadingProfile, seriesId?: number) {
|
||||
if (seriesId) {
|
||||
return this.httpClient.post(this.baseUrl + "ReadingProfile?seriesCtx="+seriesId, profile);
|
||||
}
|
||||
updateProfile(profile: ReadingProfile) {
|
||||
return this.httpClient.post(this.baseUrl + "ReadingProfile", profile);
|
||||
}
|
||||
|
||||
|
|
@ -59,4 +56,8 @@ export class ReadingProfileService {
|
|||
return this.httpClient.delete(this.baseUrl + `ReadingProfile/library/${libraryId}?profileId=${id}`, {});
|
||||
}
|
||||
|
||||
batchAddToSeries(id: number, seriesIds: number[]) {
|
||||
return this.httpClient.post(this.baseUrl + `ReadingProfile/batch?profileId=${id}`, seriesIds);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ export class ReaderSettingsComponent implements OnInit {
|
|||
}
|
||||
|
||||
savePref() {
|
||||
this.readingProfileService.updateProfile(this.packReadingProfile(), this.seriesId).subscribe()
|
||||
this.readingProfileService.updateProfile(this.packReadingProfile()).subscribe()
|
||||
}
|
||||
|
||||
private packReadingProfile(): ReadingProfile {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<ng-container *transloco="let t; prefix: 'bulk-add-to-reading-profile'">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<form style="width: 100%" [formGroup]="profileForm">
|
||||
<div class="modal-body">
|
||||
@if (profiles.length >= MaxItems) {
|
||||
<div class="mb-3">
|
||||
<label for="filter" class="form-label">{{t('filter-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="clear()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="list-group">
|
||||
@for(profile of profiles | filter: filterList; let i = $index; track profile.name) {
|
||||
<li class="list-group-item clickable" tabindex="0" role="option" (click)="addToProfile(profile)">
|
||||
{{profile.name}}
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (profiles.length === 0 && !loading) {
|
||||
<li class="list-group-item">{{t('no-data')}}</li>
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<li class="list-group-item">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.clickable:hover, .clickable:focus {
|
||||
background-color: var(--list-group-hover-bg-color, --primary-color);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
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 {ReadingList} from "../../../_models/reading-list";
|
||||
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
||||
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
|
||||
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bulk-add-to-reading-profile',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
FilterPipe,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './bulk-add-to-reading-profile.component.html',
|
||||
styleUrl: './bulk-add-to-reading-profile.component.scss'
|
||||
})
|
||||
export class BulkAddToReadingProfileComponent 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;
|
||||
|
||||
@Input({required: true}) title!: string;
|
||||
/**
|
||||
* Series Ids to add to Collection Tag
|
||||
*/
|
||||
@Input() seriesIds: Array<number> = [];
|
||||
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
|
||||
|
||||
profiles: Array<ReadingProfile> = [];
|
||||
loading: boolean = false;
|
||||
profileForm: FormGroup = new FormGroup({});
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.profileForm.addControl('title', new FormControl(this.title, []));
|
||||
this.profileForm.addControl('filterQuery', new FormControl('', []));
|
||||
|
||||
this.loading = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.readingProfileService.all().subscribe(profiles => {
|
||||
this.profiles = profiles;
|
||||
this.loading = 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 === 0) return;
|
||||
|
||||
this.readingProfileService.batchAddToSeries(profile.id, this.seriesIds).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.series-added-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('');
|
||||
}
|
||||
}
|
||||
|
|
@ -144,7 +144,7 @@ export class BulkSelectionService {
|
|||
*/
|
||||
getActions(callback: (action: ActionItem<any>, 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.AddToReadingProfile];
|
||||
|
||||
if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) {
|
||||
return this.applyFilterToList(this.actionFactory.getSeriesActions(callback), allowedActions);
|
||||
|
|
|
|||
|
|
@ -276,6 +276,9 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
case Action.Download:
|
||||
this.downloadService.download('series', this.series);
|
||||
break;
|
||||
case Action.AddToReadingProfile:
|
||||
this.actionService.addMultipleToReadingProfile([this.series]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,14 @@ export class LibraryDetailComponent implements OnInit {
|
|||
this.loadPage();
|
||||
});
|
||||
break;
|
||||
case Action.AddToReadingProfile:
|
||||
this.actionService.addMultipleToReadingProfile(selectedSeries, (success) => {
|
||||
this.bulkLoader = false;
|
||||
this.cdRef.markForCheck();
|
||||
if (!success) return;
|
||||
this.bulkSelectionService.deselectAll();
|
||||
this.loadPage();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@
|
|||
}
|
||||
</div>
|
||||
|
||||
@if (menuOpen) {
|
||||
@if (menuOpen && readingProfile !== null) {
|
||||
<div class="fixed-bottom overlay" [@slideFromBottom]="menuOpen">
|
||||
@if (pageOptions !== undefined && pageOptions.ceil !== undefined) {
|
||||
<div class="mb-3">
|
||||
|
|
|
|||
|
|
@ -501,14 +501,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
forkJoin([
|
||||
this.accountService.currentUser$.pipe(take(1)),
|
||||
this.readingProfileService.getForSeries(this.seriesId)])
|
||||
.subscribe(([user, profile]) => {
|
||||
this.readingProfileService.getForSeries(this.seriesId)
|
||||
]).subscribe(([user, profile]) => {
|
||||
if (!user) {
|
||||
this.router.navigateByUrl('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
this.readingProfile = profile;
|
||||
if (!this.readingProfile) return; // type hints
|
||||
|
||||
this.user = user;
|
||||
this.hasBookmarkRights = this.accountService.hasBookmarkRole(user) || this.accountService.hasAdminRole(user);
|
||||
|
|
@ -533,7 +534,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
});
|
||||
|
||||
// Update implicit reading profile while changing settings
|
||||
this.generalSettingsForm.valueChanges.pipe(
|
||||
/*this.generalSettingsForm.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
|
|
@ -544,7 +545,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
})
|
||||
})
|
||||
).subscribe();
|
||||
).subscribe();*/
|
||||
|
||||
|
||||
this.readerModeSubject.next(this.readerMode);
|
||||
|
|
@ -1754,7 +1755,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
// menu only code
|
||||
savePref() {
|
||||
this.readingProfileService.updateProfile(this.packReadingProfile(), this.seriesId).subscribe(_ => {
|
||||
this.readingProfileService.updateProfile(this.packReadingProfile()).subscribe(_ => {
|
||||
this.toastr.success(translate('manga-reader.user-preferences-updated'));
|
||||
})
|
||||
}
|
||||
|
|
@ -1775,8 +1776,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
data.emulateBook = modelSettings.emulateBook;
|
||||
data.swipeToPaginate = modelSettings.swipeToPaginate;
|
||||
data.pageSplitOption = parseInt(modelSettings.pageSplitOption, 10);
|
||||
// TODO: Check if this saves correctly!
|
||||
data.widthOverride = modelSettings.widthSlider === 'none' ? null : modelSettings.widthOverride;
|
||||
data.widthOverride = modelSettings.widthSlider === 'none' ? null : modelSettings.widthSlider;
|
||||
return data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,6 @@ export class ManageReadingProfilesComponent implements OnInit {
|
|||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
console.log(this.user.preferences.defaultReadingProfileId);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -247,6 +246,8 @@ export class ManageReadingProfilesComponent implements OnInit {
|
|||
private packData(): ReadingProfile {
|
||||
const data: ReadingProfile = this.readingProfileForm!.getRawValue();
|
||||
data.id = this.selectedProfile!.id;
|
||||
// Hack around readerMode being sent as a string otherwise
|
||||
data.readerMode = parseInt(data.readerMode as unknown as string);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1266,6 +1266,16 @@
|
|||
"create": "{{common.create}}"
|
||||
},
|
||||
|
||||
"bulk-add-to-reading-profile": {
|
||||
"title": "Add to Reading profile",
|
||||
"close": "{{common.close}}",
|
||||
"filter-label": "{{common.filter}}",
|
||||
"clear": "{{common.clear}}",
|
||||
"no-data": "No collections created yet",
|
||||
"loading": "{{common.loading}}",
|
||||
"create": "{{common.create}}"
|
||||
},
|
||||
|
||||
"entity-title": {
|
||||
"special": "Special",
|
||||
"issue-num": "{{common.issue-hash-num}}",
|
||||
|
|
@ -2650,7 +2660,8 @@
|
|||
"bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?",
|
||||
"match-success": "Series matched correctly",
|
||||
"webtoon-override": "Switching to Webtoon mode due to images representing a webtoon.",
|
||||
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services."
|
||||
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.",
|
||||
"series-added-to-reading-profile": "Series added to Reading Profile {{name}}"
|
||||
},
|
||||
|
||||
"read-time-pipe": {
|
||||
|
|
@ -2703,6 +2714,7 @@
|
|||
"remove-from-want-to-read-tooltip": "Remove series from Want to Read",
|
||||
"remove-from-on-deck": "Remove From On Deck",
|
||||
"remove-from-on-deck-tooltip": "Remove series from showing from On Deck",
|
||||
"add-to-reading-profile": "Add to Reading Profile",
|
||||
|
||||
"others": "Others",
|
||||
"add-to-reading-list": "Add to Reading List",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue