Kavita/API/Controllers/ReadingListController.cs
Joseph Milazzo 4e49aa47ce
Change Detection: On Push aka UI Smoothness (#1369)
* Updated Series Info Cards to use OnPush and hooked in progress events when we do a mark as read/unread on entities. These events update progress bars but also will now trigger a re-calculation on Read Time Left.

* Removed Library Card Component

* Refactored manga reader title and subtitle calculation to the backend.

* Coverted card actionables to onPush

* Series Card on push cleanup

* Updated edit collection tags for on push

* Update cover image chooser for on push

* Cleaned up carsouel reel

* Updated cover image to allow for uploading gif and webp files

* Bulk add to collection on push

* Updated bulk operation to use on push. Updated bulk operation to have mark as unread and read buttons explicitly. Updated so add to collection is visible and delete.

Fixed a bug where manage library component wasn't invoking the trackBy function

* Updating entity title for on push

* Removed file info component

* Updated Mange Library for on push

* Entity info cards on push

* List item on push

* Updated icon and title for on push and fixed some missing change detection on series detail

* Restricted the typeahead interface to simplify the design

* Edit Series Relation now shows a value in the dropdown for Parent relationships and disables the field.

* Updated edit series relation to focus on new typeahead when adding a new relationship

* Added some documentation and when Scanning a library, don't allow the user to enqueue the same job multiple times.

* Applied the No-enqueue if already enqueued logic to other tasks

* Library detail on push

* Updated events widget to onpush

* Card detail drawer on push. Card detail cover chooser now will show all chapter's covers for selection in cover chooser.

* Chapter metadata detail on push

* Removed Card Detail modal

* All collections on push

* Removed some comments

* Updated bulk selection to use an observable rather than function calls so new on push strategy works

* collection detail now uses on push and scroller is placed on correct element

* Updated library recommended to on push. Ensure that when mark as read occurs, the appropriate streams are refreshed.

* Updated library detail to on push

* Update metadata fiter to onpush. Bugs found and reported to Project

* person badge on push

* Read more on push

* Updated tag badge to on push

* User login on push

* When initing side nav, don't call an authenticated api until we are sure a user is logged in

* Updated splash container to on push

* Dashboard on push

* Side nav slight refactor around some api calls

* Cleaned up series card on push to use same cdRef naming convention

* Updated Static Files to use caching

* Added width and height to logo image

* shortcuts modal on push

* reading lists on push

* Reading list detail on push

* draggable ordered list on push

* Refactored reading-list-detail to use a new item which drastically reduces renders on operations

* series format on push

* circular loader on push

* Badge Expander on push

* update notification modal on push

* drawer on push

* Edit Series Modal on push

* reset password on push

* review series modal on push

* series metadata detail on push

* theme manager on push

* confirm reset password on push

* register on push

* confirm migration email on push

* confirm email on push

* add email to account migration on push

* user preferences on push. Made global settings default open

* edit series relation on push

* Fixed an edge case bug for next chapter where if the current volume had a single chapter of 1 and the next volume had a chapter number of 0, it would say there are no more chapters.

* Updated infinite scroller with on push support

* Moved some animations over to typeahead, not integrated yet.

* Manga reader is now on push

* Reader settings on push

* refactored how we close the book

* Updated table of contents for on push

* Updated book reader for on push. Fixed a bug where table of contents wasn't showing current page anchor due to a scroll calulation bug

* Small code tweak

* Icon and title on push

* nav header on push

* grouped typeahead on push

* typeahead on push and added a new trackby identity function to allow even faster rendering of big lists

* pdf reader on push

* code cleanup
2022-07-11 08:57:07 -07:00

522 lines
20 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.DTOs.ReadingLists;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.SignalR;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
public class ReadingListController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub)
{
_unitOfWork = unitOfWork;
_eventHub = eventHub;
}
/// <summary>
/// Fetches a single Reading List
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetList(int readingListId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, userId));
}
/// <summary>
/// Returns reading lists (paginated) for a given user.
/// </summary>
/// <param name="includePromoted">Defaults to true</param>
/// <returns></returns>
[HttpPost("lists")]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted,
userParams);
Response.AddPaginationHeader(items.CurrentPage, items.PageSize, items.TotalCount, items.TotalPages);
return Ok(items);
}
[HttpGet("lists-for-series")]
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForSeries(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(userId, seriesId, true);
return Ok(items);
}
/// <summary>
/// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress
/// </summary>
/// <remarks>This call is expensive</remarks>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("items")]
public async Task<ActionResult<IEnumerable<ReadingListItemDto>>> GetListForUser(int readingListId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
return Ok(items);
}
/// <summary>
/// Updates an items position
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update-position")]
public async Task<ActionResult> UpdateListItemPosition(UpdateReadingListPosition dto)
{
// Make sure UI buffers events
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList();
var item = items.Find(r => r.Id == dto.ReadingListItemId);
items.Remove(item);
items.Insert(dto.ToPosition, item);
for (var i = 0; i < items.Count; i++)
{
items[i].Order = i;
}
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
{
return Ok("Updated");
}
return BadRequest("Couldn't update position");
}
/// <summary>
/// Deletes a list item from the list. Will reorder all item positions afterwards
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete-item")]
public async Task<ActionResult> DeleteListItem(UpdateReadingListPosition dto)
{
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
readingList.Items = readingList.Items.Where(r => r.Id != dto.ReadingListItemId).ToList();
var index = 0;
foreach (var readingListItem in readingList.Items)
{
readingListItem.Order = index;
index++;
}
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync())
{
return Ok("Updated");
}
return BadRequest("Couldn't delete item");
}
/// <summary>
/// Removes all entries that are fully read from the reading list
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpPost("remove-read")]
public async Task<ActionResult> DeleteReadFromList([FromQuery] int readingListId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
items = await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList());
// Collect all Ids to remove
var itemIdsToRemove = items.Where(item => item.PagesRead == item.PagesTotal).Select(item => item.Id);
try
{
var listItems =
(await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).Where(r =>
itemIdsToRemove.Contains(r.Id));
_unitOfWork.ReadingListRepository.BulkRemove(listItems);
if (!_unitOfWork.HasChanges()) return Ok("Nothing to remove");
await _unitOfWork.CommitAsync();
return Ok("Updated");
}
catch
{
await _unitOfWork.RollbackAsync();
}
return BadRequest("Could not remove read items");
}
/// <summary>
/// Deletes a reading list
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpDelete]
public async Task<ActionResult> DeleteList([FromQuery] int readingListId)
{
var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var readingList = user.ReadingLists.SingleOrDefault(r => r.Id == readingListId);
if (readingList == null && !isAdmin)
{
return BadRequest("User is not associated with this reading list");
}
readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId);
user.ReadingLists.Remove(readingList);
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
{
return Ok("Deleted");
}
return BadRequest("There was an issue deleting reading list");
}
/// <summary>
/// Creates a new List with a unique title. Returns the new ReadingList back
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("create")]
public async Task<ActionResult<ReadingListDto>> CreateList(CreateReadingListDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
// When creating, we need to make sure Title is unique
var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title));
if (hasExisting)
{
return BadRequest("A list of this name already exists");
}
user.ReadingLists.Add(DbFactory.ReadingList(dto.Title, string.Empty, false));
if (!_unitOfWork.HasChanges()) return BadRequest("There was a problem creating list");
await _unitOfWork.CommitAsync();
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(dto.Title));
}
/// <summary>
/// Update the properties (title, summary) of a reading list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update")]
public async Task<ActionResult> UpdateList(UpdateReadingListDto dto)
{
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId);
if (readingList == null) return BadRequest("List does not exist");
if (!string.IsNullOrEmpty(dto.Title))
{
readingList.Title = dto.Title; // Should I check if this is unique?
readingList.NormalizedTitle = Parser.Parser.Normalize(readingList.Title);
}
if (!string.IsNullOrEmpty(dto.Title))
{
readingList.Summary = dto.Summary;
}
readingList.Promoted = dto.Promoted;
readingList.CoverImageLocked = dto.CoverImageLocked;
if (!dto.CoverImageLocked)
{
readingList.CoverImageLocked = false;
readingList.CoverImage = string.Empty;
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(readingList.Id, MessageFactoryEntityTypes.ReadingList), false);
_unitOfWork.ReadingListRepository.Update(readingList);
}
_unitOfWork.ReadingListRepository.Update(readingList);
if (await _unitOfWork.CommitAsync())
{
return Ok("Updated");
}
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)
{
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 chapterIdsForSeries =
await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId});
// If there are adds, tell tracking this has been modified
if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, 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 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)
{
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 chapterIdsForVolume =
(await _unitOfWork.ChapterRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList();
// If there are adds, tell tracking this has been modified
if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, 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-chapter")]
public async Task<ActionResult> UpdateListByChapter(UpdateReadingListByChapterDto 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");
// If there are adds, tell tracking this has been modified
if (await AddChaptersToReadingList(dto.SeriesId, new List<int>() { dto.ChapterId }, 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 a list of Chapters as reading list items to the passed reading list.
/// </summary>
/// <param name="seriesId"></param>
/// <param name="chapterIds"></param>
/// <param name="readingList"></param>
/// <returns>True if new chapters were added</returns>
private async Task<bool> AddChaptersToReadingList(int seriesId, IList<int> chapterIds,
ReadingList readingList)
{
readingList.Items ??= new List<ReadingListItem>();
var lastOrder = 0;
if (readingList.Items.Any())
{
lastOrder = readingList.Items.DefaultIfEmpty().Max(rli => rli.Order);
}
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds))
.OrderBy(c => Parser.Parser.MinNumberFromRange(c.Volume.Name))
.ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
var index = lastOrder + 1;
foreach (var chapter in chaptersForSeries)
{
if (existingChapterExists.Contains(chapter.Id)) continue;
readingList.Items.Add(DbFactory.ReadingListItem(index, seriesId, chapter.VolumeId, chapter.Id));
index += 1;
}
return index > lastOrder + 1;
}
/// <summary>
/// Returns the next chapter within the reading list
/// </summary>
/// <param name="currentChapterId"></param>
/// <param name="readingListId"></param>
/// <returns>Chapter Id for next item, -1 if nothing exists</returns>
[HttpGet("next-chapter")]
public async Task<ActionResult<int>> GetNextChapter(int currentChapterId, int readingListId)
{
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList();
var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId);
if (readingListItem == null) return BadRequest("Id does not exist");
var index = items.IndexOf(readingListItem) + 1;
if (items.Count > index)
{
return items[index].ChapterId;
}
return Ok(-1);
}
/// <summary>
/// Returns the prev chapter within the reading list
/// </summary>
/// <param name="currentChapterId"></param>
/// <param name="readingListId"></param>
/// <returns>Chapter Id for next item, -1 if nothing exists</returns>
[HttpGet("prev-chapter")]
public async Task<ActionResult<int>> GetPrevChapter(int currentChapterId, int readingListId)
{
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList();
var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId);
if (readingListItem == null) return BadRequest("Id does not exist");
var index = items.IndexOf(readingListItem) - 1;
if (0 <= index)
{
return items[index].ChapterId;
}
return Ok(-1);
}
}
}