Bulk Operations (#596)

* Implemented the ability to perform multi-selections on cards. Basic selection code is done, CSS needed and exposing actions.

* Implemented a bulk selection bar. Added logic to the card on when to force show checkboxes.

* Fixed some bad parsing groups and cases for Comic Chapters.

* Hooked up some bulk actions on series detail page. Not hooked up to backend yet.

* Fixes #593. URI Enocde library names as sometimes they can have & in them.

* Implemented the ability to mark volume/chapters as read/unread.

* Hooked up mark as unread with specials as well.

* Add to reading list hooked up for Series Detail

* Implemented ability to add multiple series to a reading list.

* Implemented bulk selection for series cards

* Added comments to the new code in ReaderService.cs

* Implemented proper styling on bulk operation bar and integrated for collections.

* Fixed an issue with shift clicking

* Cleaned up css of bulk operations bar

* Code cleanup
This commit is contained in:
Joseph Milazzo 2021-09-24 17:27:47 -07:00 committed by GitHub
parent 52c4285168
commit f5229fd0e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1129 additions and 172 deletions

View File

@ -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));

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

View File

@ -101,27 +101,7 @@ namespace API.Controllers
user.Progresses ??= new List<AppUserProgress>();
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<AppUserProgress>()
{
user.Progresses.First()
};
userProgress = user.Progresses.First();
}
}
return userProgress;
}
/// <summary>
/// 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<AppUserProgress>();
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<AppUserProgress>();
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");
}
/// <summary>
/// Marks all chapters within a list of volumes as Read. All volumes must belong to the same Series.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("mark-multiple-read")]
public async Task<ActionResult> MarkMultipleAsRead(MarkVolumesReadDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
user.Progresses ??= new List<AppUserProgress>();
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");
}
/// <summary>
/// Marks all chapters within a list of volumes as Unread. All volumes must belong to the same Series.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("mark-multiple-unread")]
public async Task<ActionResult> MarkMultipleAsUnread(MarkVolumesReadDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
user.Progresses ??= new List<AppUserProgress>();
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");
}
/// <summary>
/// Marks all chapters within a list of series as Read.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("mark-multiple-series-read")]
public async Task<ActionResult> MarkMultipleSeriesAsRead(MarkMultipleSeriesAsReadDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
user.Progresses ??= new List<AppUserProgress>();
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");
}
/// <summary>
/// Marks all chapters within a list of series as Unread.
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("mark-multiple-series-unread")]
public async Task<ActionResult> MarkMultipleSeriesAsUnread(MarkMultipleSeriesAsReadDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
user.Progresses ??= new List<AppUserProgress>();
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);

View File

@ -212,7 +212,7 @@ namespace API.Controllers
}
/// <summary>
/// Update a
/// Update the properites (title, summary) of a reading list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
@ -242,6 +242,11 @@ namespace API.Controllers
return BadRequest("Could not update reading list");
}
/// <summary>
/// Adds all chapters from a Series to a reading list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-by-series")]
public async Task<ActionResult> UpdateListBySeries(UpdateReadingListBySeriesDto dto)
{
@ -273,6 +278,86 @@ namespace API.Controllers
return Ok("Nothing to do");
}
/// <summary>
/// Adds all chapters from a list of volumes and chapters to a reading list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-by-multiple")]
public async Task<ActionResult> 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");
}
/// <summary>
/// Adds all chapters from a list of series to a reading list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-by-multiple-series")]
public async Task<ActionResult> 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<ActionResult> UpdateListByVolume(UpdateReadingListByVolumeDto dto)
{

View File

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.Reader
{
public class MarkMultipleSeriesAsReadDto
{
public IReadOnlyList<int> SeriesIds { get; init; }
}
}

View File

@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace API.DTOs.Reader
{
/// <summary>
/// This is used for bulk updating a set of volume and or chapters in one go
/// </summary>
public class MarkVolumesReadDto
{
public int SeriesId { get; set; }
/// <summary>
/// A list of Volumes to mark read
/// </summary>
public IReadOnlyList<int> VolumeIds { get; set; }
/// <summary>
/// A list of additional Chapters to mark as read
/// </summary>
public IReadOnlyList<int> ChapterIds { get; set; }
}
}

View File

@ -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<int> VolumeIds { get; init; }
public IReadOnlyList<int> ChapterIds { get; init; }
}
}

View File

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace API.DTOs.ReadingLists
{
public class UpdateReadingListByMultipleSeriesDto
{
public int ReadingListId { get; init; }
public IReadOnlyList<int> SeriesIds { get; init; }
}
}

View File

@ -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
/// </summary>
/// <param name="seriesIds"></param>
/// <returns></returns>
public async Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(int[] seriesIds)
public async Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> 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<bool> DeleteSeriesAsync(int seriesId)
@ -237,6 +243,35 @@ namespace API.Data.Repositories
return chapterIds.ToArray();
}
/// <summary>
/// This returns a list of tuples<chapterId, seriesId> back for each series id passed
/// </summary>
/// <param name="seriesIds"></param>
/// <returns></returns>
public async Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds)
{
var volumes = await _context.Volume
.Where(v => seriesIds.Contains(v.SeriesId))
.Include(v => v.Chapters)
.ToListAsync();
var seriesChapters = new Dictionary<int, IList<int>>();
foreach (var v in volumes)
{
foreach (var c in v.Chapters)
{
if (!seriesChapters.ContainsKey(v.SeriesId))
{
var list = new List<int>();
seriesChapters.Add(v.SeriesId, list);
}
seriesChapters[v.SeriesId].Add(c.Id);
}
}
return seriesChapters;
}
public async Task AddSeriesModifiers(int userId, List<SeriesDto> series)
{
var userProgress = await _context.AppUserProgresses

View File

@ -44,5 +44,13 @@ namespace API.Data.Repositories
.AsNoTracking()
.SingleOrDefaultAsync();
}
public async Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds)
{
return await _context.Chapter
.Where(c => volumeIds.Contains(c.VolumeId))
.Select(c => c.Id)
.ToListAsync();
}
}
}

View File

@ -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
/// <param name="volumeId"></param>
/// <returns></returns>
Task<VolumeDto> GetVolumeDtoAsync(int volumeId);
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(int[] seriesIds);
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
Task<bool> DeleteSeriesAsync(int seriesId);
Task<Volume> GetVolumeByIdAsync(int volumeId);
Task<Series> GetSeriesByIdAsync(int seriesId);
Task<int[]> GetChapterIdsForSeriesAsync(int[] seriesIds);
Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
/// <summary>
/// Used to add Progress/Rating information to series list.
/// </summary>

View File

@ -10,5 +10,6 @@ namespace API.Interfaces.Repositories
void Update(Volume volume);
Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
Task<string> GetVolumeCoverImageAsync(int volumeId);
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
}
}

View File

@ -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<Chapter> chapters);
void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
Task<int> CapPageToChapter(int chapterId, int page);
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);

View File

@ -25,6 +25,99 @@ namespace API.Interfaces.Services
_logger = logger;
}
/// <summary>
/// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit.
/// </summary>
/// <param name="user"></param>
/// <param name="seriesId"></param>
/// <param name="chapters"></param>
public void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable<Chapter> 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;
}
}
}
/// <summary>
/// Marks all Chapters as Unread by creating or updating UserProgress rows. Does not commit.
/// </summary>
/// <param name="user"></param>
/// <param name="seriesId"></param>
/// <param name="chapters"></param>
public void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> 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;
}
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="user"></param>
/// <param name="chapter"></param>
/// <returns></returns>
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<AppUserProgress>()
{
user.Progresses.First()
};
userProgress = user.Progresses.First();
}
}
return userProgress;
}
/// <summary>
/// Saves progress to DB
/// </summary>
@ -82,6 +175,12 @@ namespace API.Interfaces.Services
return false;
}
/// <summary>
/// Ensures that the page is within 0 and total pages for a chapter. Makes one DB call.
/// </summary>
/// <param name="chapterId"></param>
/// <param name="page"></param>
/// <returns></returns>
public async Task<int> CapPageToChapter(int chapterId, int page)
{
var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);

View File

@ -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(
@"^(?<Series>.*)(?: |_)v(?<Volume>\d+)(?: |_)(c? ?)(?<Chapter>(\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(
@"^(?<Series>.*)(?: (?<Volume>\d+))",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Batman & Robin the Teen Wonder #0
new Regex(
@"^(?<Series>.*)(?: |_)#(?<Volume>\d+)",
@"^(?<Series>.+?)(?: |_)v(?<Volume>\d+)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr
new Regex(
@"^(?<Series>.*)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-",
@"^(?<Series>.+?)(?: |_)(c? ?)(?<Chapter>(\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(
@"^(?<Series>.+?)(?: (?<Chapter>\d+))",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Batman & Robin the Teen Wonder #0
new Regex(
@"^(?<Series>.+?)(?:\s|_)#(?<Chapter>\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)(?<Series>.*)( |_)c(hapter)( |_)(?<Chapter>\d*)",
@"^(?!Vol)(?<Series>.+?)( |_)c(hapter)( |_)(?<Chapter>\d*)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
// Amazing Man Comics issue #25
new Regex(
@"^(?!Vol)(?<Series>.*)( |_)i(ssue)( |_) #(?<Chapter>\d*)",
@"^(?!Vol)(?<Series>.+?)( |_)i(ssue)( |_) #(?<Chapter>\d*)",
RegexOptions.IgnoreCase | RegexOptions.Compiled,
RegexTimeout),
};

View File

@ -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<Volume>, chapters?: Array<Chapter>, 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<Volume>, chapters?: Array<Chapter>, 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<Series>, 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<Series>, 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<Volume>, chapters?: Array<Chapter>, 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<Series>, 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' });

View File

@ -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<string[]>(this.baseUrl + 'library/list' + query);

View File

@ -68,9 +68,26 @@ export class ReaderService {
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId});
}
markMultipleRead(seriesId: number, volumeIds: Array<number>, chapterIds?: Array<number>) {
return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-read', {seriesId, volumeIds, chapterIds});
}
markMultipleUnread(seriesId: number, volumeIds: Array<number>, chapterIds?: Array<number>) {
return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-unread', {seriesId, volumeIds, chapterIds});
}
markMultipleSeriesRead(seriesIds: Array<number>) {
return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-series-read', {seriesIds});
}
markMultipleSeriesUnread(seriesIds: Array<number>) {
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) {

View File

@ -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<number>, chapterIds?: Array<number>) {
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple', {readingListId, seriesId, volumeIds, chapterIds});
}
updateByMultipleSeries(readingListId: number, seriesIds: Array<number>) {
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' });
}

View File

@ -0,0 +1,8 @@
<div *ngIf="bulkSelectionService.hasSelections()" class="bulk-select mb-3 fixed-top" [ngStyle]="{'margin-top': topOffset + 'px'}">
<div class="d-flex justify-content-around align-items-center">
<span class="highlight"><i class="fa fa-check" aria-hidden="true"></i>&nbsp;{{bulkSelectionService.totalSelections()}} selected</span>
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
<span id="bulk-actions-header" class="sr-only">Bulk Actions</span>
<button class="btn btn-icon" (click)="bulkSelectionService.deselectAll()"><i class="fa fa-times" aria-hidden="true"></i>&nbsp;Deselect All</button>
</div>
</div>

View File

@ -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;
}

View File

@ -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<any>) {
if (typeof action.callback === 'function') {
action.callback(action.action, null);
}
}
}

View File

@ -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);
}
}
}

View File

@ -39,7 +39,4 @@ export class CardActionablesComponent implements OnInit {
}
}
// TODO: Insert hr to separate admin actions
}

View File

@ -1,5 +1,5 @@
<div class="card">
<div class="overlay" (click)="handleClick()">
<div class="overlay" (click)="handleClick($event)">
<img *ngIf="total > 0 || supressArchiveWarning" class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageUrl"
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
<img *ngIf="total === 0 && !supressArchiveWarning" class="img-top lazyload" [src]="imageService.errorImage" [attr.data-src]="imageUrl"
@ -17,20 +17,22 @@
<div class="error-banner" *ngIf="total === 0 && !supressArchiveWarning">
Cannot Read
</div>
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div>
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection">
<input type="checkbox" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
</div>
</div>
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
<div>
<span class="card-title" placement="top" ngbTooltip="{{title}}" (click)="handleClick()" tabindex="0">
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" ngbTooltip="{{title}}" (click)="handleClick()" tabindex="0">
<span *ngIf="isPromoted()">
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
<span class="sr-only">(promoted)</span>
</span>
<i class="fa {{utilityService.mangaFormatIcon(format)}}" aria-hidden="true" *ngIf="format != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(format)}}"></i><span class="sr-only">{{utilityService.mangaFormat(format)}}</span>
&nbsp;{{title}}
<span class="sr-only">(promoted)</span>
</span>
<span class="card-actions float-right">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>

View File

@ -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;
}

View File

@ -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<any>[] = [];
@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<string>();
libraryName: string | undefined = undefined; // Library name item belongs to
/**
* When the card is selected.
*/
@Output() selection = new EventEmitter<boolean>();
/**
* 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<Download> | 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);
}
}

View File

@ -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 { }

View File

@ -1,3 +1,6 @@
<ng-container *ngIf="data !== undefined">
<app-card-item [title]="data.name" [actions]="actions" [supressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl" [entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"></app-card-item>
<app-card-item [title]="data.name" [actions]="actions" [supressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl"
[entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"
[allowSelection]="allowSelection" (selection)="selection.emit(selected)" [selected]="selected"
></app-card-item>
</ng-container>

View File

@ -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<Series>();
@Output() reload = new EventEmitter<boolean>();
@Output() dataChanged = new EventEmitter<Series>();
/**
* When the card is selected.
*/
@Output() selection = new EventEmitter<boolean>();
isAdmin = false;
actions: ActionItem<Series>[] = [];

View File

@ -11,14 +11,6 @@
</h2>
</div>
<div class="row no-gutters mt-2 mb-2">
<!-- <div>
<button class="btn btn-primary" (click)="read()" [disabled]="isLoading">
<span>
<i class="fa fa-book-open"></i>
</span>
<span class="read-btn--text">&nbsp;Read</span>
</button>
</div> -->
<div class="ml-2" *ngIf="isAdmin">
<button class="btn btn-secondary" (click)="openEditCollectionTagModal(collectionTag)" title="Edit Series information">
<span>
@ -33,6 +25,7 @@
</div>
</div>
<hr>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout
header="Series"
@ -44,7 +37,9 @@
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"></app-series-card>
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"
></app-series-card>
</ng-template>
</app-card-detail-layout>

View File

@ -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;

View File

@ -1,3 +1,4 @@
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout header="{{libraryName}}"
[isLoading]="loadingSeries"
[items]="series"
@ -8,6 +9,6 @@
(pageChange)="onPageChange($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"></app-series-card>
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</ng-template>
</app-card-detail-layout>

View File

@ -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> = library;
if (library === undefined) {

View File

@ -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<number>;
/**
* Only used in Multiple flow
*/
@Input() chapterIds?: Array<number>;
/**
* Only used in Multiple_Series flow
*/
@Input() seriesIds?: Array<number>;
/**
* 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<HTMLInputElement>;
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();
});
}

View File

@ -97,15 +97,16 @@
</div>
<div>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav-tabs nav-pills" [destroyOnHide]="false">
<li [ngbNavItem]="1" *ngIf="hasSpecials">
<a ngbNavLink>Specials</a>
<ng-template ngbNavContent>
<div class="row">
<div *ngFor="let chapter of specials; trackBy: trackByChapterIdentity">
<div *ngFor="let chapter of specials; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto" *ngIf="chapter.isSpecial" [entity]="chapter" [title]="chapter.title || chapter.range" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions"></app-card-item>
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('special', idx, chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('special', idx)" [allowSelection]="true"></app-card-item>
</div>
</div>
</ng-template>
@ -114,16 +115,16 @@
<a ngbNavLink>Volumes/Chapters</a>
<ng-template ngbNavContent>
<div class="row">
<div *ngFor="let volume of volumes; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="'Volume ' + volume.name" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions"></app-card-item>
</div>
<div *ngFor="let chapter of chapters; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="'Chapter ' + chapter.range" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions"></app-card-item>
</div>
<div *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="'Volume ' + volume.name" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions" (selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)" [selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true"></app-card-item>
</div>
<div *ngFor="let chapter of chapters; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="'Chapter ' + chapter.range" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
</div>
</div>
</ng-template>
</li>

View File

@ -1,17 +1,18 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs';
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
import { ConfirmService } from '../shared/confirm.service';
import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component';
import { DownloadService } from '../shared/_services/download.service';
import { UtilityService } from '../shared/_services/utility.service';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
import { Chapter } from '../_models/chapter';
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
@ -53,6 +54,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
seriesActions: ActionItem<Series>[] = [];
volumeActions: ActionItem<Volume>[] = [];
chapterActions: ActionItem<Chapter>[] = [];
bulkActions: ActionItem<any>[] = [];
hasSpecials = false;
specials: Array<Chapter> = [];
@ -88,6 +90,48 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
*/
trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.number}_${item.pagesRead}`;
bulkActionCallback = (action: Action, data: any) => {
console.log('handling bulk action callback');
if (this.series === undefined) {
return;
}
const seriesId = this.series.id;
// we need to figure out what is actually selected now
const selectedVolumeIndexes = this.bulkSelectionService.getSelectedCardsForSource('volume');
const selectedChapterIndexes = this.bulkSelectionService.getSelectedCardsForSource('chapter');
const selectedSpecialIndexes = this.bulkSelectionService.getSelectedCardsForSource('special');
const selectedChapterIds = this.chapters.filter((chapter, index: number) => selectedChapterIndexes.includes(index + ''));
const selectedVolumeIds = this.volumes.filter((volume, index: number) => selectedVolumeIndexes.includes(index + ''));
const selectedSpecials = this.specials.filter((chapter, index: number) => selectedSpecialIndexes.includes(index + ''));
const chapters = [...selectedChapterIds, ...selectedSpecials];
switch (action) {
case Action.AddToReadingList:
this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, () => this.actionInProgress = false);
break;
case Action.MarkAsRead:
console.log('marking volumes as read: ', selectedVolumeIds)
console.log('marking chapters as read: ', chapters)
this.actionService.markMultipleAsRead(seriesId, selectedVolumeIds, chapters, () => {
this.setContinuePoint();
this.actionInProgress = false;
});
break;
case Action.MarkAsUnread:
console.log('marking volumes as unread: ', selectedVolumeIds)
console.log('marking chapters as unread: ', chapters)
this.actionService.markMultipleAsUnread(seriesId, selectedVolumeIds, chapters, () => {
this.setContinuePoint();
this.actionInProgress = false;
});
break;
}
}
private onDestroy: Subject<void> = new Subject();
@ -111,7 +155,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
private actionFactoryService: ActionFactoryService, private libraryService: LibraryService,
private confirmService: ConfirmService, private titleService: Title,
private downloadService: DownloadService, private actionService: ActionService,
public imageSerivce: ImageService, private messageHub: MessageHubService) {
public imageSerivce: ImageService, private messageHub: MessageHubService,
public bulkSelectionService: BulkSelectionService) {
ratingConfig.max = 5;
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
@ -151,6 +196,20 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.onDestroy.complete();
}
@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;
}
}
handleSeriesActionCallback(action: Action, series: Series) {
this.actionInProgress = true;
switch(action) {
@ -281,6 +340,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
this.seriesService.getVolumes(this.series.id).subscribe(volumes => {
this.chapters = volumes.filter(v => v.number === 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters);
@ -490,7 +550,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
}
downloadSeries() {
this.downloadService.downloadSeriesSize(this.series.id).pipe(take(1)).subscribe(async (size) => {
const wantToDownload = await this.downloadService.confirmSize(size, 'series');
if (!wantToDownload) { return; }

View File

@ -15,7 +15,8 @@ export enum KEY_CODES {
G = 'g',
B = 'b',
BACKSPACE = 'Backspace',
DELETE = 'Delete'
DELETE = 'Delete',
SHIFT = 'Shift'
}
export enum Breakpoint {