From 48e6b5a0803b1f61795d84958ca67eed973f3e62 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Wed, 16 Apr 2025 21:24:47 +0200 Subject: [PATCH] Support deleting multiple volumes, fixes #3737 The UI doesn't seem to update while the VolumeDeleteEvent is send out --- API/Controllers/VolumeController.cs | 29 ++++++++++++++++++- API/Data/Repositories/VolumeRepository.cs | 15 ++++++++++ UI/Web/src/app/_services/action.service.ts | 12 +++++++- UI/Web/src/app/_services/volume.service.ts | 4 +++ .../series-detail/series-detail.component.ts | 26 +++++++++++++---- UI/Web/src/assets/langs/en.json | 1 + 6 files changed, 79 insertions(+), 8 deletions(-) diff --git a/API/Controllers/VolumeController.cs b/API/Controllers/VolumeController.cs index 7181f6eef..564802322 100644 --- a/API/Controllers/VolumeController.cs +++ b/API/Controllers/VolumeController.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -54,4 +56,29 @@ public class VolumeController : BaseApiController return Ok(false); } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("multiple")] + public async Task> DeleteMultipleVolumes(int[] volumesIds) + { + var volumes = (await _unitOfWork.VolumeRepository.GetVolumesById(volumesIds)).ToList(); + if (volumes.Count != volumesIds.Length) + { + return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); + } + + _unitOfWork.VolumeRepository.Remove(volumes); + + if (!await _unitOfWork.CommitAsync()) + { + return Ok(false); + } + + foreach (var volume in volumes) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false); + } + + return Ok(true); + } } diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index cb0783a89..ed7167928 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -35,6 +35,7 @@ public interface IVolumeRepository void Add(Volume volume); void Update(Volume volume); void Remove(Volume volume); + void Remove(IList volumes); Task> GetFilesForVolume(int volumeId); Task GetVolumeCoverImageAsync(int volumeId); Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); @@ -43,6 +44,7 @@ public interface IVolumeRepository Task GetVolumeDtoAsync(int volumeId, int userId); Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task> GetVolumes(int seriesId); + Task> GetVolumesById(IList volumeIds, VolumeIncludes includes = VolumeIncludes.None); Task GetVolumeByIdAsync(int volumeId); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task> GetCoverImagesForLockedVolumesAsync(); @@ -72,6 +74,10 @@ public class VolumeRepository : IVolumeRepository { _context.Volume.Remove(volume); } + public void Remove(IList volumes) + { + _context.Volume.RemoveRange(volumes); + } /// /// Returns a list of non-tracked files for a given volume. @@ -180,6 +186,15 @@ public class VolumeRepository : IVolumeRepository .OrderBy(vol => vol.MinNumber) .ToListAsync(); } + public async Task> GetVolumesById(IList volumeIds, VolumeIncludes includes = VolumeIncludes.None) + { + return await _context.Volume + .Where(vol => volumeIds.Contains(vol.Id)) + .Includes(includes) + .AsSplitQuery() + .OrderBy(vol => vol.MinNumber) + .ToListAsync(); + } /// /// Returns a single volume with Chapter and Files diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 8bf6cdacd..51d928ace 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -472,8 +472,18 @@ export class ActionService { }); } + async deleteMultipleVolumes(volumes: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return; + + this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => { + if (callback) { + callback(success); + } + }) + } + async deleteMultipleChapters(seriesId: number, chapterIds: Array, callback?: BooleanActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters'))) return; + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: chapterIds.length}))) return; this.chapterService.deleteMultipleChapters(seriesId, chapterIds.map(c => c.id)).subscribe(() => { if (callback) { diff --git a/UI/Web/src/app/_services/volume.service.ts b/UI/Web/src/app/_services/volume.service.ts index 16857b3d2..f53a20543 100644 --- a/UI/Web/src/app/_services/volume.service.ts +++ b/UI/Web/src/app/_services/volume.service.ts @@ -21,6 +21,10 @@ export class VolumeService { return this.httpClient.delete(this.baseUrl + 'volume?volumeId=' + volumeId); } + deleteMultipleVolumes(volumeIds: number[]) { + return this.httpClient.post(this.baseUrl + "volume/multiple", volumeIds) + } + updateVolume(volume: any) { return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse); } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 890237be4..8553df705 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -353,11 +353,25 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.cdRef.markForCheck(); break; case Action.Delete: - await this.actionService.deleteMultipleChapters(seriesId, chapters, () => { - // No need to update the page as the backend will spam volume/chapter deletions - this.bulkSelectionService.deselectAll(); - this.cdRef.markForCheck(); - }); + if (chapters.length > 0) { + await this.actionService.deleteMultipleChapters(seriesId, chapters, () => { + // No need to update the page as the backend will spam volume/chapter deletions + this.bulkSelectionService.deselectAll(); + this.cdRef.markForCheck(); + }); + + // It's not possible to select both chapters and volumes + break; + } + + if (selectedVolumeIds.length > 0) { + await this.actionService.deleteMultipleVolumes(selectedVolumeIds, () => { + // No need to update the page as the backend will spam volume deletions + this.bulkSelectionService.deselectAll(); + this.cdRef.markForCheck(); + }); + } + break; } } @@ -660,7 +674,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { case (Action.Delete): await this.actionService.deleteChapter(chapter.id, (success) => { if (!success) return; - + this.chapters = this.chapters.filter(c => c.id != chapter.id); this.cdRef.markForCheck(); }); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 4d8a07210..dc409848f 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -2628,6 +2628,7 @@ "alert-long-running": "This is a long running process. Please give it the time to complete before invoking again.", "confirm-delete-multiple-series": "Are you sure you want to delete {{count}} series? It will not modify files on disk.", "confirm-delete-multiple-chapters": "Are you sure you want to delete {{count}} chapter/volumes? It will not modify files on disk.", + "confirm-delete-multiple-volumes": "Are you sure you want to delete {{count}} volumes? It will not modify files on disk.", "confirm-delete-series": "Are you sure you want to delete this series? It will not modify files on disk.", "confirm-delete-chapter": "Are you sure you want to delete this chapter? It will not modify files on disk.", "confirm-delete-volume": "Are you sure you want to delete this volume? It will not modify files on disk.",