diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index 0267a2ceb..8d25661ff 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -58,17 +58,17 @@ namespace API.Tests.Parser } [Theory] - [InlineData("01 Spider-Man & Wolverine 01.cbr", "0")] + [InlineData("01 Spider-Man & Wolverine 01.cbr", "1")] [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")] [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "0")] - [InlineData("Batman & Catwoman - Trail of the Gun 01", "0")] + [InlineData("Batman & Catwoman - Trail of the Gun 01", "1")] [InlineData("Batman & Daredevil - King of New York", "0")] [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "1")] [InlineData("Batman & Robin the Teen Wonder #0", "0")] [InlineData("Batman & Wildcat (1 of 3)", "1")] [InlineData("Batman & Wildcat (2 of 3)", "2")] - [InlineData("Batman And Superman World's Finest #01", "0")] - [InlineData("Babe 01", "0")] + [InlineData("Batman And Superman World's Finest #01", "1")] + [InlineData("Babe 01", "1")] [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "1")] [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] [InlineData("Superman v1 024 (09-10 1943)", "24")] @@ -78,6 +78,8 @@ namespace API.Tests.Parser [InlineData("Batman Wayne Family Adventures - Ep. 014 - Moving In", "14")] [InlineData("Saga 001 (2012) (Digital) (Empire-Zone)", "1")] [InlineData("Batman Beyond 04 (of 6) (1999)", "4")] + [InlineData("Invincible 052 (c2c) (2008) (Minutemen-TheCouple)", "52")] + [InlineData("Y - The Last Man #001", "1")] public void ParseComicChapterTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename)); diff --git a/API.Tests/Services/Test Data/ImageService/cover.expected.jpg b/API.Tests/Services/Test Data/ImageService/cover.expected.jpg new file mode 100644 index 000000000..73da78f50 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/cover.expected.jpg differ diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index ee8934b28..d1314674e 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -101,27 +101,7 @@ namespace API.Controllers user.Progresses ??= new List(); foreach (var volume in volumes) { - foreach (var chapter in volume.Chapters) - { - var userProgress = GetUserProgressForChapter(user, chapter); - - if (userProgress == null) - { - user.Progresses.Add(new AppUserProgress - { - PagesRead = chapter.Pages, - VolumeId = volume.Id, - SeriesId = markReadDto.SeriesId, - ChapterId = chapter.Id - }); - } - else - { - userProgress.PagesRead = chapter.Pages; - userProgress.SeriesId = markReadDto.SeriesId; - userProgress.VolumeId = volume.Id; - } - } + _readerService.MarkChaptersAsRead(user, markReadDto.SeriesId, volume.Chapters); } _unitOfWork.UserRepository.Update(user); @@ -135,30 +115,6 @@ namespace API.Controllers return BadRequest("There was an issue saving progress"); } - private static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter) - { - AppUserProgress userProgress = null; - try - { - userProgress = - user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id); - } - catch (Exception) - { - // There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages - var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList(); - if (progresses.Count > 1) - { - user.Progresses = new List() - { - user.Progresses.First() - }; - userProgress = user.Progresses.First(); - } - } - - return userProgress; - } /// /// Marks a Series as Unread (progress) @@ -175,7 +131,7 @@ namespace API.Controllers { foreach (var chapter in volume.Chapters) { - var userProgress = GetUserProgressForChapter(user, chapter); + var userProgress = ReaderService.GetUserProgressForChapter(user, chapter); if (userProgress == null) continue; userProgress.PagesRead = 0; @@ -206,28 +162,7 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); - foreach (var chapter in chapters) - { - user.Progresses ??= new List(); - var userProgress = user.Progresses.FirstOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id); - - if (userProgress == null) - { - user.Progresses.Add(new AppUserProgress - { - PagesRead = 0, - VolumeId = markVolumeReadDto.VolumeId, - SeriesId = markVolumeReadDto.SeriesId, - ChapterId = chapter.Id - }); - } - else - { - userProgress.PagesRead = 0; - userProgress.SeriesId = markVolumeReadDto.SeriesId; - userProgress.VolumeId = markVolumeReadDto.VolumeId; - } - } + _readerService.MarkChaptersAsUnread(user, markVolumeReadDto.SeriesId, chapters); _unitOfWork.UserRepository.Update(user); @@ -250,27 +185,119 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); - foreach (var chapter in chapters) - { - user.Progresses ??= new List(); - var userProgress = user.Progresses.FirstOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id); + _readerService.MarkChaptersAsRead(user, markVolumeReadDto.SeriesId, chapters); - if (userProgress == null) - { - user.Progresses.Add(new AppUserProgress - { - PagesRead = chapter.Pages, - VolumeId = markVolumeReadDto.VolumeId, - SeriesId = markVolumeReadDto.SeriesId, - ChapterId = chapter.Id - }); - } - else - { - userProgress.PagesRead = chapter.Pages; - userProgress.SeriesId = markVolumeReadDto.SeriesId; - userProgress.VolumeId = markVolumeReadDto.VolumeId; - } + _unitOfWork.UserRepository.Update(user); + + if (await _unitOfWork.CommitAsync()) + { + return Ok(); + } + + return BadRequest("Could not save progress"); + } + + + /// + /// Marks all chapters within a list of volumes as Read. All volumes must belong to the same Series. + /// + /// + /// + [HttpPost("mark-multiple-read")] + public async Task MarkMultipleAsRead(MarkVolumesReadDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); + + var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); + foreach (var chapterId in dto.ChapterIds) + { + chapterIds.Add(chapterId); + } + var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds); + _readerService.MarkChaptersAsRead(user, dto.SeriesId, chapters); + + _unitOfWork.UserRepository.Update(user); + + if (await _unitOfWork.CommitAsync()) + { + return Ok(); + } + + return BadRequest("Could not save progress"); + } + + /// + /// Marks all chapters within a list of volumes as Unread. All volumes must belong to the same Series. + /// + /// + /// + [HttpPost("mark-multiple-unread")] + public async Task MarkMultipleAsUnread(MarkVolumesReadDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); + + var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); + foreach (var chapterId in dto.ChapterIds) + { + chapterIds.Add(chapterId); + } + var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds); + _readerService.MarkChaptersAsUnread(user, dto.SeriesId, chapters); + + _unitOfWork.UserRepository.Update(user); + + if (await _unitOfWork.CommitAsync()) + { + return Ok(); + } + + return BadRequest("Could not save progress"); + } + + /// + /// Marks all chapters within a list of series as Read. + /// + /// + /// + [HttpPost("mark-multiple-series-read")] + public async Task MarkMultipleSeriesAsRead(MarkMultipleSeriesAsReadDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); + + var volumes = await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); + foreach (var volume in volumes) + { + _readerService.MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); + } + + _unitOfWork.UserRepository.Update(user); + + if (await _unitOfWork.CommitAsync()) + { + return Ok(); + } + + return BadRequest("Could not save progress"); + } + + /// + /// Marks all chapters within a list of series as Unread. + /// + /// + /// + [HttpPost("mark-multiple-series-unread")] + public async Task MarkMultipleSeriesAsUnread(MarkMultipleSeriesAsReadDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); + + var volumes = await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); + foreach (var volume in volumes) + { + _readerService.MarkChaptersAsUnread(user, volume.SeriesId, volume.Chapters); } _unitOfWork.UserRepository.Update(user); diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 789742ab0..1f22263c7 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -212,7 +212,7 @@ namespace API.Controllers } /// - /// Update a + /// Update the properites (title, summary) of a reading list /// /// /// @@ -242,6 +242,11 @@ namespace API.Controllers return BadRequest("Could not update reading list"); } + /// + /// Adds all chapters from a Series to a reading list + /// + /// + /// [HttpPost("update-by-series")] public async Task UpdateListBySeries(UpdateReadingListBySeriesDto dto) { @@ -273,6 +278,86 @@ namespace API.Controllers return Ok("Nothing to do"); } + + /// + /// Adds all chapters from a list of volumes and chapters to a reading list + /// + /// + /// + [HttpPost("update-by-multiple")] + public async Task UpdateListByMultiple(UpdateReadingListByMultipleDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername()); + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); + + var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); + foreach (var chapterId in dto.ChapterIds) + { + chapterIds.Add(chapterId); + } + + // If there are adds, tell tracking this has been modified + if (await AddChaptersToReadingList(dto.SeriesId, chapterIds, readingList)) + { + _unitOfWork.ReadingListRepository.Update(readingList); + } + + try + { + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + return Ok("Updated"); + } + } + catch + { + await _unitOfWork.RollbackAsync(); + } + + return Ok("Nothing to do"); + } + + /// + /// Adds all chapters from a list of series to a reading list + /// + /// + /// + [HttpPost("update-by-multiple-series")] + public async Task UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername()); + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); + + var ids = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(dto.SeriesIds.ToArray()); + + foreach (var seriesId in ids.Keys) + { + // If there are adds, tell tracking this has been modified + if (await AddChaptersToReadingList(seriesId, ids[seriesId], readingList)) + { + _unitOfWork.ReadingListRepository.Update(readingList); + } + } + + try + { + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + return Ok("Updated"); + } + } + catch + { + await _unitOfWork.RollbackAsync(); + } + + return Ok("Nothing to do"); + } + [HttpPost("update-by-volume")] public async Task UpdateListByVolume(UpdateReadingListByVolumeDto dto) { diff --git a/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs new file mode 100644 index 000000000..7201658fa --- /dev/null +++ b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace API.DTOs.Reader +{ + public class MarkMultipleSeriesAsReadDto + { + public IReadOnlyList SeriesIds { get; init; } + } +} diff --git a/API/DTOs/Reader/MarkVolumesReadDto.cs b/API/DTOs/Reader/MarkVolumesReadDto.cs new file mode 100644 index 000000000..7e23e721a --- /dev/null +++ b/API/DTOs/Reader/MarkVolumesReadDto.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace API.DTOs.Reader +{ + /// + /// This is used for bulk updating a set of volume and or chapters in one go + /// + public class MarkVolumesReadDto + { + public int SeriesId { get; set; } + /// + /// A list of Volumes to mark read + /// + public IReadOnlyList VolumeIds { get; set; } + /// + /// A list of additional Chapters to mark as read + /// + public IReadOnlyList ChapterIds { get; set; } + } +} diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs new file mode 100644 index 000000000..02a41a767 --- /dev/null +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace API.DTOs.ReadingLists +{ + public class UpdateReadingListByMultipleDto + { + public int SeriesId { get; init; } + public int ReadingListId { get; init; } + public IReadOnlyList VolumeIds { get; init; } + public IReadOnlyList ChapterIds { get; init; } + } +} diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs new file mode 100644 index 000000000..4b08f95bc --- /dev/null +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace API.DTOs.ReadingLists +{ + public class UpdateReadingListByMultipleSeriesDto + { + public int ReadingListId { get; init; } + public IReadOnlyList SeriesIds { get; init; } + } +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 8eac7d0bc..3ed415859 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -188,11 +189,16 @@ namespace API.Data.Repositories /// /// /// - public async Task> GetVolumesForSeriesAsync(int[] seriesIds) + public async Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false) { - return await _context.Volume - .Where(v => seriesIds.Contains(v.SeriesId)) - .ToListAsync(); + var query = _context.Volume + .Where(v => seriesIds.Contains(v.SeriesId)); + + if (includeChapters) + { + query = query.Include(v => v.Chapters); + } + return await query.ToListAsync(); } public async Task DeleteSeriesAsync(int seriesId) @@ -237,6 +243,35 @@ namespace API.Data.Repositories return chapterIds.ToArray(); } + /// + /// This returns a list of tuples back for each series id passed + /// + /// + /// + public async Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds) + { + var volumes = await _context.Volume + .Where(v => seriesIds.Contains(v.SeriesId)) + .Include(v => v.Chapters) + .ToListAsync(); + + var seriesChapters = new Dictionary>(); + foreach (var v in volumes) + { + foreach (var c in v.Chapters) + { + if (!seriesChapters.ContainsKey(v.SeriesId)) + { + var list = new List(); + seriesChapters.Add(v.SeriesId, list); + } + seriesChapters[v.SeriesId].Add(c.Id); + } + } + + return seriesChapters; + } + public async Task AddSeriesModifiers(int userId, List series) { var userProgress = await _context.AppUserProgresses diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 860bcaf79..d991a928c 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -44,5 +44,13 @@ namespace API.Data.Repositories .AsNoTracking() .SingleOrDefaultAsync(); } + + public async Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds) + { + return await _context.Chapter + .Where(c => volumeIds.Contains(c.VolumeId)) + .Select(c => c.Id) + .ToListAsync(); + } } } diff --git a/API/Interfaces/Repositories/ISeriesRepository.cs b/API/Interfaces/Repositories/ISeriesRepository.cs index 31b77d65f..05fe937eb 100644 --- a/API/Interfaces/Repositories/ISeriesRepository.cs +++ b/API/Interfaces/Repositories/ISeriesRepository.cs @@ -1,4 +1,5 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; using API.DTOs; @@ -44,11 +45,12 @@ namespace API.Interfaces.Repositories /// /// Task GetVolumeDtoAsync(int volumeId); - Task> GetVolumesForSeriesAsync(int[] seriesIds); + Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task DeleteSeriesAsync(int seriesId); Task GetVolumeByIdAsync(int volumeId); Task GetSeriesByIdAsync(int seriesId); Task GetChapterIdsForSeriesAsync(int[] seriesIds); + Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds); /// /// Used to add Progress/Rating information to series list. /// diff --git a/API/Interfaces/Repositories/IVolumeRepository.cs b/API/Interfaces/Repositories/IVolumeRepository.cs index 898175df9..62ec0ef9a 100644 --- a/API/Interfaces/Repositories/IVolumeRepository.cs +++ b/API/Interfaces/Repositories/IVolumeRepository.cs @@ -10,5 +10,6 @@ namespace API.Interfaces.Repositories void Update(Volume volume); Task> GetFilesForVolume(int volumeId); Task GetVolumeCoverImageAsync(int volumeId); + Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); } } diff --git a/API/Interfaces/Services/IReaderService.cs b/API/Interfaces/Services/IReaderService.cs index e536a7e71..a72b90699 100644 --- a/API/Interfaces/Services/IReaderService.cs +++ b/API/Interfaces/Services/IReaderService.cs @@ -1,10 +1,14 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; using API.DTOs; +using API.Entities; namespace API.Interfaces.Services { public interface IReaderService { + void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters); + void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters); Task SaveReadingProgress(ProgressDto progressDto, int userId); Task CapPageToChapter(int chapterId, int page); Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); diff --git a/API/Interfaces/Services/ReaderService.cs b/API/Interfaces/Services/ReaderService.cs index 99b7157d2..eaa3b96d7 100644 --- a/API/Interfaces/Services/ReaderService.cs +++ b/API/Interfaces/Services/ReaderService.cs @@ -25,6 +25,99 @@ namespace API.Interfaces.Services _logger = logger; } + /// + /// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit. + /// + /// + /// + /// + public void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters) + { + foreach (var chapter in chapters) + { + var userProgress = GetUserProgressForChapter(user, chapter); + + if (userProgress == null) + { + user.Progresses.Add(new AppUserProgress + { + PagesRead = chapter.Pages, + VolumeId = chapter.VolumeId, + SeriesId = seriesId, + ChapterId = chapter.Id + }); + } + else + { + userProgress.PagesRead = chapter.Pages; + userProgress.SeriesId = seriesId; + userProgress.VolumeId = chapter.VolumeId; + } + } + } + + /// + /// Marks all Chapters as Unread by creating or updating UserProgress rows. Does not commit. + /// + /// + /// + /// + public void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters) + { + foreach (var chapter in chapters) + { + var userProgress = GetUserProgressForChapter(user, chapter); + + if (userProgress == null) + { + user.Progresses.Add(new AppUserProgress + { + PagesRead = 0, + VolumeId = chapter.VolumeId, + SeriesId = seriesId, + ChapterId = chapter.Id + }); + } + else + { + userProgress.PagesRead = 0; + userProgress.SeriesId = seriesId; + userProgress.VolumeId = chapter.VolumeId; + } + } + } + + /// + /// Gets the User Progress for a given Chapter. This will handle any duplicates that might have occured in past versions and will delete them. Does not commit. + /// + /// + /// + /// + public static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter) + { + AppUserProgress userProgress = null; + try + { + userProgress = + user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id); + } + catch (Exception) + { + // There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages + var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList(); + if (progresses.Count > 1) + { + user.Progresses = new List() + { + user.Progresses.First() + }; + userProgress = user.Progresses.First(); + } + } + + return userProgress; + } + /// /// Saves progress to DB /// @@ -82,6 +175,12 @@ namespace API.Interfaces.Services return false; } + /// + /// Ensures that the page is within 0 and total pages for a chapter. Makes one DB call. + /// + /// + /// + /// public async Task CapPageToChapter(int chapterId, int page) { var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId); diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 3a06d6684..0650faf4a 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -383,22 +383,22 @@ namespace API.Parser RegexTimeout), // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( - @"^(?.*)(?: |_)v(?\d+)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), - // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) - new Regex( - @"^(?.*)(?: (?\d+))", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), - // Batman & Robin the Teen Wonder #0 - new Regex( - @"^(?.*)(?: |_)#(?\d+)", + @"^(?.+?)(?: |_)v(?\d+)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout), // Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr new Regex( - @"^(?.*)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-", + @"^(?.+?)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + RegexTimeout), + // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) + new Regex( + @"^(?.+?)(?: (?\d+))", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + RegexTimeout), + // Batman & Robin the Teen Wonder #0 + new Regex( + @"^(?.+?)(?:\s|_)#(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout), // Saga 001 (2012) (Digital) (Empire-Zone) @@ -408,12 +408,12 @@ namespace API.Parser RegexTimeout), // Amazing Man Comics chapter 25 new Regex( - @"^(?!Vol)(?.*)( |_)c(hapter)( |_)(?\d*)", + @"^(?!Vol)(?.+?)( |_)c(hapter)( |_)(?\d*)", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout), // Amazing Man Comics issue #25 new Regex( - @"^(?!Vol)(?.*)( |_)i(ssue)( |_) #(?\d*)", + @"^(?!Vol)(?.+?)( |_)i(ssue)( |_) #(?\d*)", RegexOptions.IgnoreCase | RegexOptions.Compiled, RegexTimeout), }; diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index a8af23b31..0cdfb3cfe 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -20,6 +20,7 @@ export type SeriesActionCallback = (series: Series) => void; export type VolumeActionCallback = (volume: Volume) => void; export type ChapterActionCallback = (chapter: Chapter) => void; export type ReadingListActionCallback = (readingList: ReadingList) => void; +export type VoidActionCallback = () => void; /** * Responsible for executing actions @@ -203,6 +204,85 @@ export class ActionService implements OnDestroy { }); } + /** + * Mark all chapters and the volumes as Read. All volumes and chapters must belong to a series + * @param seriesId Series Id + * @param volumes Volumes, should have id, chapters and pagesRead populated + * @param chapters? Chapters, should have id + * @param callback Optional callback to perform actions after API completes + */ + markMultipleAsRead(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { + this.readerService.markMultipleRead(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => { + volumes.forEach(volume => { + volume.pagesRead = volume.pages; + volume.chapters?.forEach(c => c.pagesRead = c.pages); + }); + chapters?.forEach(c => c.pagesRead = c.pages); + this.toastr.success('Marked as Read'); + + if (callback) { + callback(); + } + }); + } + + /** + * Mark all chapters and the volumes as Unread. All volumes must belong to a series + * @param seriesId Series Id + * @param volumes Volumes, should have id, chapters and pagesRead populated + * @param callback Optional callback to perform actions after API completes + */ + markMultipleAsUnread(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { + this.readerService.markMultipleUnread(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => { + volumes.forEach(volume => { + volume.pagesRead = volume.pages; + volume.chapters?.forEach(c => c.pagesRead = c.pages); + }); + chapters?.forEach(c => c.pagesRead = c.pages); + this.toastr.success('Marked as Read'); + + if (callback) { + callback(); + } + }); + } + + /** + * Mark all series as Read. + * @param series Series, should have id, pagesRead populated + * @param callback Optional callback to perform actions after API completes + */ + markMultipleSeriesAsRead(series: Array, callback?: VoidActionCallback) { + this.readerService.markMultipleSeriesRead(series.map(v => v.id)).pipe(take(1)).subscribe(() => { + series.forEach(s => { + s.pagesRead = s.pages; + }); + this.toastr.success('Marked as Read'); + + if (callback) { + callback(); + } + }); + } + + /** + * Mark all series as Unread. + * @param series Series, should have id, pagesRead populated + * @param callback Optional callback to perform actions after API completes + */ + markMultipleSeriesAsUnread(series: Array, callback?: VoidActionCallback) { + this.readerService.markMultipleSeriesUnread(series.map(v => v.id)).pipe(take(1)).subscribe(() => { + series.forEach(s => { + s.pagesRead = s.pages; + }); + this.toastr.success('Marked as Unread'); + + if (callback) { + callback(); + } + }); + } + openBookmarkModal(series: Series, callback?: SeriesActionCallback) { if (this.bookmarkModalRef != null) { return; } @@ -222,6 +302,52 @@ export class ActionService implements OnDestroy { }); } + addMultipleToReadingList(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { + if (this.readingListModalRef != null) { return; } + this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); + this.readingListModalRef.componentInstance.seriesId = seriesId; + this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id); + this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id); + this.readingListModalRef.componentInstance.title = 'Multiple Selections'; + this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple; + + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(); + } + }); + } + + addMultipleSeriesToReadingList(series: Array, callback?: VoidActionCallback) { + if (this.readingListModalRef != null) { return; } + this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); + this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id); + this.readingListModalRef.componentInstance.title = 'Multiple Selections'; + this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series; + + + this.readingListModalRef.closed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(); + } + }); + this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => { + this.readingListModalRef = null; + if (callback) { + callback(); + } + }); + } + addSeriesToReadingList(series: Series, callback?: SeriesActionCallback) { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' }); diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index e6991e592..2c0b06f1f 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -37,7 +37,7 @@ export class LibraryService { listDirectories(rootPath: string) { let query = ''; if (rootPath !== undefined && rootPath.length > 0) { - query = '?path=' + rootPath; + query = '?path=' + encodeURIComponent(rootPath); } return this.httpClient.get(this.baseUrl + 'library/list' + query); diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index dee47f7d0..8281ab9e9 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -68,9 +68,26 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId}); } + markMultipleRead(seriesId: number, volumeIds: Array, chapterIds?: Array) { + return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-read', {seriesId, volumeIds, chapterIds}); + } + + markMultipleUnread(seriesId: number, volumeIds: Array, chapterIds?: Array) { + return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-unread', {seriesId, volumeIds, chapterIds}); + } + + markMultipleSeriesRead(seriesIds: Array) { + return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-series-read', {seriesIds}); + } + + markMultipleSeriesUnread(seriesIds: Array) { + return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-series-unread', {seriesIds}); + } + markVolumeUnread(seriesId: number, volumeId: number) { return this.httpClient.post(this.baseUrl + 'reader/mark-volume-unread', {seriesId, volumeId}); } + getNextChapter(seriesId: number, volumeId: number, currentChapterId: number, readingListId: number = -1) { if (readingListId > 0) { diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index f7b3343d7..e520154d6 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -42,6 +42,14 @@ export class ReadingListService { return this.httpClient.post(this.baseUrl + 'readinglist/update', model, { responseType: 'text' as 'json' }); } + updateByMultiple(readingListId: number, seriesId: number, volumeIds: Array, chapterIds?: Array) { + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple', {readingListId, seriesId, volumeIds, chapterIds}); + } + + updateByMultipleSeries(readingListId: number, seriesIds: Array) { + return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds}); + } + updateBySeries(readingListId: number, seriesId: number) { return this.httpClient.post(this.baseUrl + 'readinglist/update-by-series', {readingListId, seriesId}, { responseType: 'text' as 'json' }); } diff --git a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.html b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.html new file mode 100644 index 000000000..2d328bfb3 --- /dev/null +++ b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.html @@ -0,0 +1,8 @@ +
+
+  {{bulkSelectionService.totalSelections()}} selected + + Bulk Actions + +
+
\ No newline at end of file diff --git a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.scss b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.scss new file mode 100644 index 000000000..b385680ee --- /dev/null +++ b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.scss @@ -0,0 +1,16 @@ +@import "../../../theme/colors"; +@import "../../../assets/themes/dark.scss"; + +.bulk-select { + background-color: $dark-form-background-no-opacity; + border-bottom: 2px solid $primary-color; + color: white; +} + +.btn-icon { + color: white; +} + +.highlight { + color: $primary-color !important; +} \ No newline at end of file diff --git a/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts new file mode 100644 index 000000000..6f5590339 --- /dev/null +++ b/UI/Web/src/app/cards/bulk-operations/bulk-operations.component.ts @@ -0,0 +1,39 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Action, ActionItem } from 'src/app/_services/action-factory.service'; +import { BulkSelectionService } from '../bulk-selection.service'; + +@Component({ + selector: 'app-bulk-operations', + templateUrl: './bulk-operations.component.html', + styleUrls: ['./bulk-operations.component.scss'] +}) +export class BulkOperationsComponent implements OnInit { + + @Input() actionCallback!: (action: Action, data: any) => void; + topOffset: number = 0; + + get actions() { + return this.bulkSelectionService.getActions(this.actionCallback.bind(this)); + } + + constructor(public bulkSelectionService: BulkSelectionService) { } + + ngOnInit(): void { + const navBar = document.querySelector('.navbar'); + if (navBar) { + this.topOffset = Math.ceil(navBar.getBoundingClientRect().height); + } + } + + handleActionCallback(action: Action, data: any) { + this.actionCallback(action, data); + } + + performAction(action: ActionItem) { + if (typeof action.callback === 'function') { + action.callback(action.action, null); + } + } + + +} diff --git a/UI/Web/src/app/cards/bulk-selection.service.ts b/UI/Web/src/app/cards/bulk-selection.service.ts new file mode 100644 index 000000000..47072c014 --- /dev/null +++ b/UI/Web/src/app/cards/bulk-selection.service.ts @@ -0,0 +1,147 @@ +import { Injectable } from '@angular/core'; +import { NavigationStart, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; +import { Action, ActionFactoryService } from '../_services/action-factory.service'; + +type DataSource = 'volume' | 'chapter' | 'special' | 'series'; + +/** + * Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops. + * This will clear selections between pages. + * + * Remakrs: Page which renders cards is responsible for listening for shift keydown/keyup and updating our state variable. + */ +@Injectable({ + providedIn: 'root' +}) +export class BulkSelectionService { + + private debug: boolean = false; + private prevIndex: number = 0; + private prevDataSource!: DataSource; + private selectedCards: { [key: string]: {[key: number]: boolean} } = {}; + private dataSourceMax: { [key: string]: number} = {}; + public isShiftDown: boolean = false; + + constructor(private router: Router, private actionFactory: ActionFactoryService) { + router.events + .pipe(filter(event => event instanceof NavigationStart)) + .subscribe((event) => { + this.deselectAll(); + this.dataSourceMax = {}; + this.prevIndex = 0; + }); + } + + handleCardSelection(dataSource: DataSource, index: number, maxIndex: number, wasSelected: boolean) { + if (this.isShiftDown) { + + if (dataSource === this.prevDataSource) { + this.debugLog('Selecting ' + dataSource + ' cards from ' + this.prevIndex + ' to ' + index); + this.selectCards(dataSource, this.prevIndex, index, !wasSelected); + } else { + const isForwardSelection = index < this.prevIndex; + + if (isForwardSelection) { + this.debugLog('Selecting ' + this.prevDataSource + ' cards from ' + this.prevIndex + ' to ' + this.dataSourceMax[this.prevDataSource]); + this.selectCards(this.prevDataSource, this.prevIndex, this.dataSourceMax[this.prevDataSource], !wasSelected); + this.debugLog('Selecting ' + dataSource + ' cards from ' + 0 + ' to ' + index); + this.selectCards(dataSource, 0, index, !wasSelected); + } else { + this.debugLog('Selecting ' + this.prevDataSource + ' cards from ' + 0 + ' to ' + this.prevIndex); + this.selectCards(this.prevDataSource, this.prevIndex, 0, !wasSelected); + this.debugLog('Selecting ' + dataSource + ' cards from ' + index + ' to ' + maxIndex); + this.selectCards(dataSource, index, maxIndex, !wasSelected); + } + } + } else { + this.debugLog('Selecting ' + dataSource + ' cards at ' + index); + this.selectCards(dataSource, index, index, !wasSelected); + } + this.prevIndex = index; + this.prevDataSource = dataSource; + this.dataSourceMax[dataSource] = maxIndex; + } + + isCardSelected(dataSource: DataSource, index: number) { + if (this.selectedCards.hasOwnProperty(dataSource) && this.selectedCards[dataSource].hasOwnProperty(index)) { + return this.selectedCards[dataSource][index]; + } + return false; + } + + selectCards(dataSource: DataSource, from: number, to: number, value: boolean) { + if (!this.selectedCards.hasOwnProperty(dataSource)) { + this.selectedCards[dataSource] = {}; + } + + if (from === to) { + this.selectedCards[dataSource][to] = value; + return; + } + + if (from > to) { + for (let i = to; i <= from; i++) { + this.selectedCards[dataSource][i] = value; + } + } + + for (let i = from; i <= to; i++) { + this.selectedCards[dataSource][i] = value; + } + } + + deselectAll() { + this.selectedCards = {}; + } + + hasSelections() { + const keys = Object.keys(this.selectedCards); + return keys.filter(key => { + return Object.values(this.selectedCards[key]).filter(item => item).length > 0; + }).length > 0; + } + + totalSelections() { + let sum = 0; + const keys = Object.keys(this.selectedCards); + keys.forEach(key => { + sum += Object.values(this.selectedCards[key]).filter(item => item).length; + }); + return sum; + } + + getSelectedCardsForSource(dataSource: DataSource) { + if (!this.selectedCards.hasOwnProperty(dataSource)) return []; + + let ret = []; + for(let k in this.selectedCards[dataSource]) { + if (this.selectedCards[dataSource][k]) { + ret.push(k); + } + } + + return ret; + } + + getActions(callback: (action: Action, data: any) => void) { + // checks if series is present. If so, returns only series actions + // else returns volume/chapter items + const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread]; + if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) { + return this.actionFactory.getSeriesActions(callback).filter(item => allowedActions.includes(item.action)); + } + + return this.actionFactory.getVolumeActions(callback).filter(item => allowedActions.includes(item.action)); + } + + private debugLog(message: string, extraData?: any) { + if (!this.debug) return; + + if (extraData !== undefined) { + console.log(message, extraData); + } else { + console.log(message); + } + } +} diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts index b7441e62c..fc3138cbf 100644 --- a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts @@ -39,7 +39,4 @@ export class CardActionablesComponent implements OnInit { } } - // TODO: Insert hr to separate admin actions - - } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index d2ef9853b..c4fac279a 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -1,5 +1,5 @@
-
+
Cannot Read
- -
+
+ +
+
- + + (promoted) {{utilityService.mangaFormat(format)}}  {{title}} - (promoted) diff --git a/UI/Web/src/app/cards/card-item/card-item.component.scss b/UI/Web/src/app/cards/card-item/card-item.component.scss index e1b5ceecc..1a5fef4d5 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.scss +++ b/UI/Web/src/app/cards/card-item/card-item.component.scss @@ -38,6 +38,8 @@ $image-width: 160px; margin-bottom: 0px; } + + .img-top { height: $image-height; } @@ -71,12 +73,36 @@ $image-width: 160px; border-color: transparent $primary-color transparent transparent; } + +.bulk-mode { + position: absolute; + top: 5px; + left: 5px; + visibility: hidden; + + &.always-show { + visibility: visible !important; + } + + input[type="checkbox"] { + width: 20px; + height: 20px; + } +} + + .overlay { height: $image-height; + + &:hover { visibility: visible; + .bulk-mode { + visibility: visible; + } + .overlay-item { visibility: visible; } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 80e6d2306..38c580379 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -13,6 +13,7 @@ import { Volume } from 'src/app/_models/volume'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import { ImageService } from 'src/app/_services/image.service'; import { LibraryService } from 'src/app/_services/library.service'; +import { BulkSelectionService } from '../bulk-selection.service'; @Component({ selector: 'app-card-item', @@ -21,23 +22,71 @@ import { LibraryService } from 'src/app/_services/library.service'; }) export class CardItemComponent implements OnInit, OnDestroy { + /** + * Card item url. Will internally handle error and missing covers + */ @Input() imageUrl = ''; + /** + * Name of the card + */ @Input() title = ''; + /** + * Any actions to perform on the card + */ @Input() actions: ActionItem[] = []; - @Input() read = 0; // Pages read - @Input() total = 0; // Total Pages + /** + * Pages Read + */ + @Input() read = 0; + /** + * Total Pages + */ + @Input() total = 0; + /** + * Supress library link + */ @Input() supressLibraryLink = false; - @Input() entity!: Series | Volume | Chapter | CollectionTag; // This is the entity we are representing. It will be returned if an action is executed. + /** + * This is the entity we are representing. It will be returned if an action is executed. + */ + @Input() entity!: Series | Volume | Chapter | CollectionTag; + /** + * If the entity is selected or not. + */ + @Input() selected: boolean = false; + /** + * If the entity should show selection code + */ + @Input() allowSelection: boolean = false; + /** + * Event emitted when item is clicked + */ @Output() clicked = new EventEmitter(); - - libraryName: string | undefined = undefined; // Library name item belongs to + /** + * When the card is selected. + */ + @Output() selection = new EventEmitter(); + /** + * Library name item belongs to + */ + libraryName: string | undefined = undefined; libraryId: number | undefined = undefined; - supressArchiveWarning: boolean = false; // This will supress the cannot read archive warning when total pages is 0 + /** + * This will supress the cannot read archive warning when total pages is 0 + */ + supressArchiveWarning: boolean = false; + /** + * Format of the entity (only applies to Series) + */ format: MangaFormat = MangaFormat.UNKNOWN; + download$: Observable | null = null; downloadInProgress: boolean = false; + isShiftDown: boolean = false; + + get MangaFormat(): typeof MangaFormat { return MangaFormat; } @@ -46,7 +95,7 @@ export class CardItemComponent implements OnInit, OnDestroy { constructor(public imageService: ImageService, private libraryService: LibraryService, public utilityService: UtilityService, private downloadService: DownloadService, - private toastr: ToastrService) {} + private toastr: ToastrService, public bulkSelectionService: BulkSelectionService) {} ngOnInit(): void { if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) { @@ -69,7 +118,7 @@ export class CardItemComponent implements OnInit, OnDestroy { this.onDestroy.complete(); } - handleClick() { + handleClick(event?: any) { this.clicked.emit(this.title); } @@ -146,7 +195,14 @@ export class CardItemComponent implements OnInit, OnDestroy { isPromoted() { const tag = this.entity as CollectionTag; - // TODO: Validate if this works with reading lists return tag.hasOwnProperty('promoted') && tag.promoted; } + + + handleSelection(event?: any) { + if (event) { + event.stopPropagation(); + } + this.selection.emit(this.selected); + } } diff --git a/UI/Web/src/app/cards/cards.module.ts b/UI/Web/src/app/cards/cards.module.ts index c9d427307..7c7db5c54 100644 --- a/UI/Web/src/app/cards/cards.module.ts +++ b/UI/Web/src/app/cards/cards.module.ts @@ -19,6 +19,7 @@ import { TypeaheadModule } from '../typeahead/typeahead.module'; import { BrowserModule } from '@angular/platform-browser'; import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layout.component'; import { CardDetailsModalComponent } from './_modals/card-details-modal/card-details-modal.component'; +import { BulkOperationsComponent } from './bulk-operations/bulk-operations.component'; @@ -34,7 +35,8 @@ import { CardDetailsModalComponent } from './_modals/card-details-modal/card-det BookmarksModalComponent, CardActionablesComponent, CardDetailLayoutComponent, - CardDetailsModalComponent + CardDetailsModalComponent, + BulkOperationsComponent ], imports: [ CommonModule, @@ -70,7 +72,8 @@ import { CardDetailsModalComponent } from './_modals/card-details-modal/card-det BookmarksModalComponent, CardActionablesComponent, CardDetailLayoutComponent, - CardDetailsModalComponent + CardDetailsModalComponent, + BulkOperationsComponent ] }) export class CardsModule { } diff --git a/UI/Web/src/app/cards/series-card/series-card.component.html b/UI/Web/src/app/cards/series-card/series-card.component.html index 18190842f..8653e3f9d 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.html +++ b/UI/Web/src/app/cards/series-card/series-card.component.html @@ -1,3 +1,6 @@ - + \ No newline at end of file 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 e94d78ffc..57c2af992 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 @@ -21,9 +21,22 @@ export class SeriesCardComponent implements OnInit, OnChanges { @Input() data!: Series; @Input() libraryId = 0; @Input() suppressLibraryLink = false; + /** + * If the entity is selected or not. + */ + @Input() selected: boolean = false; + /** + * If the entity should show selection code + */ + @Input() allowSelection: boolean = false; + @Output() clicked = new EventEmitter(); @Output() reload = new EventEmitter(); @Output() dataChanged = new EventEmitter(); + /** + * When the card is selected. + */ + @Output() selection = new EventEmitter(); isAdmin = false; actions: ActionItem[] = []; diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html index ff77357ef..431680257 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html @@ -11,14 +11,6 @@
-

+ - + diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts index e7013f34e..b592098d8 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts @@ -1,19 +1,20 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, HostListener, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { Router, ActivatedRoute } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; +import { BulkSelectionService } from 'src/app/cards/bulk-selection.service'; import { UpdateFilterEvent } from 'src/app/cards/card-detail-layout/card-detail-layout.component'; import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component'; -import { UtilityService } from 'src/app/shared/_services/utility.service'; +import { KEY_CODES } from 'src/app/shared/_services/utility.service'; import { CollectionTag } from 'src/app/_models/collection-tag'; -import { MangaFormat } from 'src/app/_models/manga-format'; import { Pagination } from 'src/app/_models/pagination'; import { Series } from 'src/app/_models/series'; import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter'; import { AccountService } from 'src/app/_services/account.service'; import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; +import { ActionService } from 'src/app/_services/action.service'; import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import { ImageService } from 'src/app/_services/image.service'; import { SeriesService } from 'src/app/_services/series.service'; @@ -39,9 +40,31 @@ export class CollectionDetailComponent implements OnInit { mangaFormat: null }; + bulkActionCallback = (action: Action, data: any) => { + const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); + const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); + + switch (action) { + case Action.AddToReadingList: + this.actionService.addMultipleSeriesToReadingList(selectedSeries); + break; + case Action.MarkAsRead: + this.actionService.markMultipleSeriesAsRead(selectedSeries, () => { + this.loadPage(); + }); + break; + case Action.MarkAsUnread: + this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => { + this.loadPage(); + }); + break; + } + } + constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService, - private modalService: NgbModal, private titleService: Title, private accountService: AccountService, private utilityService: UtilityService) { + private modalService: NgbModal, private titleService: Title, private accountService: AccountService, + public bulkSelectionService: BulkSelectionService, private actionService: ActionService) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.accountService.currentUser$.pipe(take(1)).subscribe(user => { @@ -63,6 +86,20 @@ export class CollectionDetailComponent implements OnInit { this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)); } + @HostListener('document:keydown.shift', ['$event']) + handleKeypress(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = true; + } + } + + @HostListener('document:keyup.shift', ['$event']) + handleKeyUp(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = false; + } + } + updateTag(tagId: number) { this.collectionService.allTags().subscribe(tags => { this.collections = tags; diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index 652e0c849..6d7885385 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -1,3 +1,4 @@ + - + 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 01ad75c1f..133f6e0cb 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -1,8 +1,10 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, HostListener, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs/operators'; +import { BulkSelectionService } from '../cards/bulk-selection.service'; import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component'; +import { KEY_CODES } from '../shared/_services/utility.service'; import { Library } from '../_models/library'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; @@ -30,9 +32,39 @@ export class LibraryDetailComponent implements OnInit { mangaFormat: null }; + bulkActionCallback = (action: Action, data: any) => { + console.log('handling bulk action callback'); + // we need to figure out what is actually selected now + const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); + + const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); + + switch (action) { + case Action.AddToReadingList: + this.actionService.addMultipleSeriesToReadingList(selectedSeries); + break; + case Action.MarkAsRead: + console.log('marking series as read: ', selectedSeries) + + this.actionService.markMultipleSeriesAsRead(selectedSeries, () => { + this.loadPage(); + }); + + break; + case Action.MarkAsUnread: + //console.log('marking volumes as unread: ', selectedVolumeIds) + //console.log('marking chapters as unread: ', chapters) + + this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => { + this.loadPage(); + }); + break; + } + } + constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService, private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, - private actionService: ActionService) { + private actionService: ActionService, public bulkSelectionService: BulkSelectionService) { const routeId = this.route.snapshot.paramMap.get('id'); if (routeId === null) { this.router.navigateByUrl('/libraries'); @@ -53,6 +85,20 @@ export class LibraryDetailComponent implements OnInit { } + @HostListener('document:keydown.shift', ['$event']) + handleKeypress(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = true; + } + } + + @HostListener('document:keyup.shift', ['$event']) + handleKeyUp(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = false; + } + } + handleAction(action: Action, library: Library) { let lib: Partial = library; if (library === undefined) { diff --git a/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.ts b/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.ts index 4c51f88af..a33d69332 100644 --- a/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.ts +++ b/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.ts @@ -1,13 +1,17 @@ +import { noUndefined } from '@angular/compiler/src/util'; import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; import { ReadingList } from 'src/app/_models/reading-list'; import { ReadingListService } from 'src/app/_services/reading-list.service'; export enum ADD_FLOW { Series = 0, Volume = 1, - Chapter = 2 + Chapter = 2, + Multiple = 3, + Multiple_Series } @Component({ @@ -18,9 +22,31 @@ export enum ADD_FLOW { export class AddToListModalComponent implements OnInit, AfterViewInit { @Input() title!: string; + /** + * Only used in Series flow + */ @Input() seriesId?: number; + /** + * Only used in Volume flow + */ @Input() volumeId?: number; + /** + * Only used in Chapter flow + */ @Input() chapterId?: number; + /** + * Only used in Multiple flow + */ + @Input() volumeIds?: Array; + /** + * Only used in Multiple flow + */ + @Input() chapterIds?: Array; + /** + * Only used in Multiple_Series flow + */ + @Input() seriesIds?: Array; + /** * Determines which Input is required and which API is used to associate to the Reading List */ @@ -36,7 +62,7 @@ export class AddToListModalComponent implements OnInit, AfterViewInit { @ViewChild('title') inputElem!: ElementRef; - constructor(private modal: NgbActiveModal, private readingListService: ReadingListService) { } + constructor(private modal: NgbActiveModal, private readingListService: ReadingListService, private toastr: ToastrService) { } ngOnInit(): void { @@ -70,18 +96,34 @@ export class AddToListModalComponent implements OnInit, AfterViewInit { } addToList(readingList: ReadingList) { + + if (this.type === ADD_FLOW.Multiple_Series && this.seriesIds !== undefined) { + this.readingListService.updateByMultipleSeries(readingList.id, this.seriesIds).subscribe(() => { + this.toastr.success('Series added to reading list'); + this.modal.close(); + }); + } + if (this.seriesId === undefined) return; - if (this.type === ADD_FLOW.Series) { + if (this.type === ADD_FLOW.Series && this.seriesId !== undefined) { this.readingListService.updateBySeries(readingList.id, this.seriesId).subscribe(() => { + this.toastr.success('Series added to reading list'); this.modal.close(); }); } else if (this.type === ADD_FLOW.Volume && this.volumeId !== undefined) { this.readingListService.updateByVolume(readingList.id, this.seriesId, this.volumeId).subscribe(() => { + this.toastr.success('Volumes added to reading list'); this.modal.close(); }); } else if (this.type === ADD_FLOW.Chapter && this.chapterId !== undefined) { this.readingListService.updateByChapter(readingList.id, this.seriesId, this.chapterId).subscribe(() => { + this.toastr.success('Chapter added to reading list'); + this.modal.close(); + }); + } else if (this.type === ADD_FLOW.Multiple && this.volumeIds !== undefined && this.chapterIds !== undefined) { + this.readingListService.updateByMultiple(readingList.id, this.seriesId, this.volumeIds, this.chapterIds).subscribe(() => { + this.toastr.success('Chapters and Volumes added to reading list'); this.modal.close(); }); } diff --git a/UI/Web/src/app/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/series-detail.component.html index f5c8e1939..9a6edebab 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -97,15 +97,16 @@
+