From 483c90904dd15c8de3e99768f9a06868788f474a Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Thu, 29 May 2025 22:29:18 +0200 Subject: [PATCH] Bulk actions and nicer behaviour with implicit profiles --- .../Services/ReadingProfileServiceTest.cs | 171 ++++++++++++++---- API/Controllers/ReadingProfileController.cs | 38 ++-- .../AppUserReadingProfileRepository.cs | 44 +++++ API/Services/ReadingProfileService.cs | 114 +++++++----- .../app/_services/action-factory.service.ts | 14 ++ UI/Web/src/app/_services/action.service.ts | 29 +++ .../app/_services/reading-profile.service.ts | 9 +- .../reader-settings.component.ts | 2 +- ...bulk-add-to-reading-profile.component.html | 43 +++++ ...bulk-add-to-reading-profile.component.scss | 3 + .../bulk-add-to-reading-profile.component.ts | 81 +++++++++ .../src/app/cards/bulk-selection.service.ts | 2 +- .../series-card/series-card.component.ts | 3 + .../library-detail.component.ts | 8 + .../manga-reader/manga-reader.component.html | 2 +- .../manga-reader/manga-reader.component.ts | 14 +- .../manage-reading-profiles.component.ts | 3 +- UI/Web/src/assets/langs/en.json | 14 +- 18 files changed, 481 insertions(+), 113 deletions(-) create mode 100644 UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.html create mode 100644 UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.scss create mode 100644 UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.ts diff --git a/API.Tests/Services/ReadingProfileServiceTest.cs b/API.Tests/Services/ReadingProfileServiceTest.cs index 907f7c7b8..fd2358b47 100644 --- a/API.Tests/Services/ReadingProfileServiceTest.cs +++ b/API.Tests/Services/ReadingProfileServiceTest.cs @@ -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(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(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(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); diff --git a/API/Controllers/ReadingProfileController.cs b/API/Controllers/ReadingProfileController.cs index 2bdf5eb40..97671b8ca 100644 --- a/API/Controllers/ReadingProfileController.cs +++ b/API/Controllers/ReadingProfileController.cs @@ -49,26 +49,15 @@ public class ReadingProfileController(ILogger logger, /// Updates the given reading profile, must belong to the current user /// /// - /// - /// Optionally, from which series the update is called. - /// If set, will delete the implicit reading profile if it exists - /// /// - /// This does not update connected series, and libraries. Use - /// , , - /// , + /// + /// This does not update connected series, and libraries. + /// Deletes all implicit profiles for series linked to this profile /// [HttpPost] - public async Task UpdateReadingProfile([FromBody] UserReadingProfileDto dto, [FromQuery] int? seriesCtx) + public async Task 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 logger, [HttpPost("series")] public async Task 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 logger, return Ok(); } + /// + /// Assigns the reading profile to all passes series, and deletes their implicit profiles + /// + /// + /// + /// + [HttpPost("batch")] + public async Task BatchAddReadingProfile([FromQuery] int profileId, [FromBody] IList seriesIds) + { + await readingProfileService.BatchAddProfileToSeries(User.GetUserId(), profileId, seriesIds); + return Ok(); + } + } diff --git a/API/Data/Repositories/AppUserReadingProfileRepository.cs b/API/Data/Repositories/AppUserReadingProfileRepository.cs index d6a61e4cb..27694d7fb 100644 --- a/API/Data/Repositories/AppUserReadingProfileRepository.cs +++ b/API/Data/Repositories/AppUserReadingProfileRepository.cs @@ -26,6 +26,7 @@ public interface IAppUserReadingProfileRepository Task> GetProfilesForUser(int userId, bool nonImplicitOnly, ReadingProfileIncludes includes = ReadingProfileIncludes.None); Task> GetProfilesDtoForUser(int userId, bool nonImplicitOnly, ReadingProfileIncludes includes = ReadingProfileIncludes.None); Task GetProfileForSeries(int userId, int seriesId, ReadingProfileIncludes includes = ReadingProfileIncludes.None); + Task> GetProfilesForSeries(int userId, IList seriesIds, bool implicitOnly, ReadingProfileIncludes includes = ReadingProfileIncludes.None); Task GetProfileDtoForSeries(int userId, int seriesId); Task GetProfileForLibrary(int userId, int libraryId, ReadingProfileIncludes includes = ReadingProfileIncludes.None); Task GetProfileDtoForLibrary(int userId, int libraryId); @@ -33,11 +34,16 @@ public interface IAppUserReadingProfileRepository Task GetProfileDto(int profileId); Task GetProfileByName(int userId, string name); Task GetSeriesProfile(int userId, int seriesId); + Task> GetSeriesProfilesForSeries(int userId, IList seriesIds); Task 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 readingProfiles); } public class AppUserReadingProfileRepository(DataContext context, IMapper mapper): IAppUserReadingProfileRepository @@ -70,6 +76,17 @@ public class AppUserReadingProfileRepository(DataContext context, IMapper mapper .FirstOrDefaultAsync(); } + public async Task> GetProfilesForSeries(int userId, IList 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 GetProfileDtoForSeries(int userId, int seriesId) { return await context.AppUserReadingProfile @@ -126,6 +143,13 @@ public class AppUserReadingProfileRepository(DataContext context, IMapper mapper .FirstOrDefaultAsync(); } + public async Task> GetSeriesProfilesForSeries(int userId, IList seriesIds) + { + return await context.SeriesReadingProfile + .Where(rp => seriesIds.Contains(rp.SeriesId) && rp.AppUserId == userId) + .ToListAsync(); + } + public async Task 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 readingProfiles) + { + context.AppUserReadingProfile.RemoveRange(readingProfiles); + } } diff --git a/API/Services/ReadingProfileService.cs b/API/Services/ReadingProfileService.cs index f6ab0b953..3de59e77e 100644 --- a/API/Services/ReadingProfileService.cs +++ b/API/Services/ReadingProfileService.cs @@ -16,7 +16,7 @@ public interface IReadingProfileService { /// /// Returns the ReadingProfile that should be applied to the given series, walks up the tree. - /// Series -> Library -> Default + /// Series (implicit) -> Series (Assigned) -> Library -> Default /// /// /// @@ -24,13 +24,13 @@ public interface IReadingProfileService Task GetReadingProfileForSeries(int userId, int seriesId); /// - /// Updates a given reading profile for a user + /// Updates a given reading profile for a user, and deletes all implicit profiles /// /// /// /// /// Does not update connected series and libraries - Task UpdateReadingProfile(int userId, UserReadingProfileDto dto); + Task UpdateReadingProfile(int userId, UserReadingProfileDto dto); /// /// Creates a new reading profile for a user. Name must be unique per user @@ -47,15 +47,7 @@ public interface IReadingProfileService /// /// /// - Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto); - - /// - /// Deletes the implicit reading profile for a given series, if it exists - /// - /// - /// - /// - Task DeleteImplicitForSeries(int userId, int seriesId); + Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto); /// /// 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 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 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 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 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 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; diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 61fee39ec..9e924f6e9 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -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: [], + }, ], }, { diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 37826b0e2..5492a21bd 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 { + BulkAddToReadingProfileComponent +} from "../cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component"; export type LibraryActionCallback = (library: Partial) => void; @@ -813,4 +816,30 @@ export class ActionService { }); } + /** + * Adds series to a reading list + * @param series + * @param callback + */ + addMultipleToReadingProfile(series: Array, 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); + } + }); + } + } diff --git a/UI/Web/src/app/_services/reading-profile.service.ts b/UI/Web/src/app/_services/reading-profile.service.ts index 928ff0fe0..7bf32e81b 100644 --- a/UI/Web/src/app/_services/reading-profile.service.ts +++ b/UI/Web/src/app/_services/reading-profile.service.ts @@ -16,10 +16,7 @@ export class ReadingProfileService { return this.httpClient.get(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); + } + } 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 f3a47774d..76d023545 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 @@ -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 { diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.html b/UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.html new file mode 100644 index 000000000..0523075a6 --- /dev/null +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.html @@ -0,0 +1,43 @@ + + + +
+ +
+ + + +
diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.scss b/UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.scss new file mode 100644 index 000000000..b3a7ca5b8 --- /dev/null +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.scss @@ -0,0 +1,3 @@ +.clickable:hover, .clickable:focus { + background-color: var(--list-group-hover-bg-color, --primary-color); +} diff --git a/UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.ts b/UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.ts new file mode 100644 index 000000000..b87f37cf5 --- /dev/null +++ b/UI/Web/src/app/cards/_modals/bulk-add-to-reading-profile/bulk-add-to-reading-profile.component.ts @@ -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 = []; + @ViewChild('title') inputElem!: ElementRef; + + profiles: Array = []; + 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(''); + } +} diff --git a/UI/Web/src/app/cards/bulk-selection.service.ts b/UI/Web/src/app/cards/bulk-selection.service.ts index ff80a0288..c6a0b702a 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.AddToReadingProfile]; 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/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index c6cfa825f..d50b8dfea 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 @@ -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; } 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..2afaa8302 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -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(); + }) } } 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 30ed6c507..7f12e3be6 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 @@ -142,7 +142,7 @@ } - @if (menuOpen) { + @if (menuOpen && readingProfile !== null) {
@if (pageOptions !== undefined && pageOptions.ceil !== undefined) {
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 03069bd6a..4d6cb6094 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 @@ -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; } diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts index 8d985fbd5..416b1b2e4 100644 --- a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts @@ -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; } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 5829ec24f..4e50653c8 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -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",