Want to Read List (#1392)

* Implemented a Want To Read list of series for all users, as a way to keep track of what you want to read.

When canceling a bulk action, like Add to Reading list, the selected cards wont de-select.

* Hooked up Remove from Want to Read

* When making bulk selection, allow the user to click on anywhere on the card

* Added no series messaging

* Code cleanup
This commit is contained in:
Joseph Milazzo 2022-07-28 17:18:35 -05:00 committed by GitHub
parent 495c986000
commit f130440bd0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 2209 additions and 48 deletions

View file

@ -178,7 +178,7 @@ namespace API.Controllers
if (!validPassword)
{
return Unauthorized("Your credentials are not correct"); // TODO: Refactor backend to send back the string for i8ln
return Unauthorized("Your credentials are not correct");
}
var result = await _signInManager

View file

@ -4,8 +4,10 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.SignalR;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;

View file

@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.WantToRead;
using API.Extensions;
using API.Helpers;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// Responsible for all things Want To Read
/// </summary>
[Route("api/want-to-read")]
public class WantToReadController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
public WantToReadController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
/// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered
/// </summary>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost]
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToRead([FromQuery] UserParams userParams, FilterDto filterDto)
{
userParams ??= new UserParams();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, userParams, filterDto);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
return Ok(pagedList);
}
/// <summary>
/// Given a list of Series Ids, add them to the current logged in user's Want To Read list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("add-series")]
public async Task<ActionResult> AddSeries(UpdateWantToReadDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
AppUserIncludes.WantToRead);
var existingIds = user.WantToRead.Select(s => s.Id).ToList();
existingIds.AddRange(dto.SeriesIds);
var idsToAdd = existingIds.Distinct().ToList();
var seriesToAdd = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(idsToAdd);
foreach (var series in seriesToAdd)
{
user.WantToRead.Add(series);
}
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync()) return Ok();
return BadRequest("There was an issue updating Read List");
}
/// <summary>
/// Given a list of Series Ids, remove them from the current logged in user's Want To Read list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("remove-series")]
public async Task<ActionResult> RemoveSeries(UpdateWantToReadDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
AppUserIncludes.WantToRead);
user.WantToRead = user.WantToRead.Where(s => @dto.SeriesIds.Contains(s.Id)).ToList();
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync()) return Ok();
return BadRequest("There was an issue updating Read List");
}
}

View file

@ -6,6 +6,9 @@
public string Title { get; set; }
public string Summary { get; set; }
public bool Promoted { get; set; }
/// <summary>
/// The cover image string. This is used on Frontend to show or hide the Cover Image
/// </summary>
public string CoverImage { get; set; }
public bool CoverImageLocked { get; set; }
}

View file

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace API.DTOs.WantToRead;
/// <summary>
/// A list of Series to pass when working with Want To Read APIs
/// </summary>
public class UpdateWantToReadDto
{
/// <summary>
/// List of Series Ids that will be Added/Removed
/// </summary>
public IList<int> SeriesIds { get; set; }
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class WantToReadList : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AppUserId",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Series_AppUserId",
table: "Series",
column: "AppUserId");
migrationBuilder.AddForeignKey(
name: "FK_Series_AspNetUsers_AppUserId",
table: "Series",
column: "AppUserId",
principalTable: "AspNetUsers",
principalColumn: "Id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Series_AspNetUsers_AppUserId",
table: "Series");
migrationBuilder.DropIndex(
name: "IX_Series_AppUserId",
table: "Series");
migrationBuilder.DropColumn(
name: "AppUserId",
table: "Series");
}
}
}

View file

@ -758,6 +758,9 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
@ -820,6 +823,8 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("LibraryId");
b.ToTable("Series");
@ -1339,6 +1344,10 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany("WantToRead")
.HasForeignKey("AppUserId");
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
@ -1533,6 +1542,8 @@ namespace API.Data.Migrations
b.Navigation("UserPreferences");
b.Navigation("UserRoles");
b.Navigation("WantToRead");
});
modelBuilder.Entity("API.Entities.Chapter", b =>

View file

@ -119,6 +119,7 @@ public interface ISeriesRepository
Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams);
Task<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId);
Task<SeriesDto> GetSeriesForChapter(int chapterId, int userId);
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
}
public class SeriesRepository : ISeriesRepository
@ -715,7 +716,6 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter)
{
var userLibraries = await GetUserLibraries(libraryId, userId);
@ -778,6 +778,68 @@ public class SeriesRepository : ISeriesRepository
return query;
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable<Series> sQuery)
{
var userLibraries = await GetUserLibraries(libraryId, userId);
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter);
var query = sQuery
.Where(s => userLibraries.Contains(s.LibraryId)
&& formats.Contains(s.Format)
&& (!hasGenresFilter || s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id)))
&& (!hasPeopleFilter || s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id)))
&& (!hasCollectionTagFilter ||
s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
&& (!hasRatingFilter || s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId))
&& (!hasProgressFilter || seriesIds.Contains(s.Id))
&& (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating))
&& (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
&& (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language))
&& (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)))
.Where(s => !hasSeriesNameFilter ||
EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.OriginalName, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%"))
.AsNoTracking();
// If no sort options, default to using SortName
filter.SortOptions ??= new SortOptions()
{
IsAscending = true,
SortField = SortField.SortName
};
if (filter.SortOptions.IsAscending)
{
query = filter.SortOptions.SortField switch
{
SortField.SortName => query.OrderBy(s => s.SortName),
SortField.CreatedDate => query.OrderBy(s => s.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
_ => query
};
}
else
{
query = filter.SortOptions.SortField switch
{
SortField.SortName => query.OrderByDescending(s => s.SortName),
SortField.CreatedDate => query.OrderByDescending(s => s.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
_ => query
};
}
return query;
}
public async Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId)
{
var metadataDto = await _context.SeriesMetadata
@ -1074,6 +1136,21 @@ public class SeriesRepository : ISeriesRepository
.SingleOrDefaultAsync();
}
public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
{
var libraryIds = GetLibraryIdsForUser(userId);
var query = _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.AsSplitQuery()
.AsNoTracking();
var filteredQuery = await CreateFilteredSearchQueryable(userId, 0, filter, query);
return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams)
{
@ -1238,7 +1315,6 @@ public class SeriesRepository : ISeriesRepository
VolumeNumber = c.Volume.Number,
ChapterTitle = c.Title
})
//.Take(maxRecords)
.AsSplitQuery()
.Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId))
.AsEnumerable();

View file

@ -22,7 +22,8 @@ public enum AppUserIncludes
Bookmarks = 4,
ReadingLists = 8,
Ratings = 16,
UserPreferences = 32
UserPreferences = 32,
WantToRead = 64
}
public interface IUserRepository
@ -176,6 +177,11 @@ public class UserRepository : IUserRepository
query = query.Include(u => u.UserPreferences);
}
if (includeFlags.HasFlag(AppUserIncludes.WantToRead))
{
query = query.Include(u => u.WantToRead);
}
return query;

View file

@ -22,6 +22,10 @@ namespace API.Entities
/// </summary>
public ICollection<ReadingList> ReadingLists { get; set; }
/// <summary>
/// A list of Series the user want's to read
/// </summary>
public ICollection<Series> WantToRead { get; set; }
/// <summary>
/// An API Key to interact with external services, like OPDS
/// </summary>
public string ApiKey { get; set; }