mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-31 12:14:44 -04:00
* Started with the redesign of the cover image chooser redesign to be less click intensive for volume/chapter images. Made some headings bold in card detail drawer. * Tweaked the styles * Moved where the info cards show * Added an ability to open a page settings drawer * Cleaned up some old code that isn't needed anymore. * Started implementing a list view. Refactored some title code to a dedicated component * List view implemented but way too many API calls. Either need caching or adjusting the SeriesDetail api. * Fixed a bug where if the progress bar didn't render on a card item while a download was in progress, the download indicator would be removed. * Large refactor to move a lot of the needed fields to the chapter and volume dtos for series detail. All fields are noted when only used in series detail. * Implemented cards for other tabs (except related) * Fixed the unit test which needed a mocked reader service call. * More cleanup around age rating and removing old code from the refactor. Commented out sorting till i feel motivated to work on that. * Some cleanup and restored cards as initial layout. Time to test this out and see if there is value add. * Added ability for Chapters tab to show the volume chapters belong to (if applicable) * Adding style fixes * Cover image updates, don't allow the first image (which is what is currently set) to respond to cover changes. Hide the ID field on list item for series detail. * Refactored the title for list item to be injectable * Cleaned up the selection code to make it less finicky on mobile when tap scrolling. * Refactored chapter tab to show volume as well on list view. * Ensure word count shows for Volumes * Started adding virtual scrolling, pushing up so Robbie can mess around * Started adding virtual scrolling, pushing up so Robbie can mess around * Fixed a bug where all chapters would come under specials * Show title data as accent if set. * Style fixes for virtual scroller * Restyling scroll * Implemented a way to show storyline with virtual scrolling * Show Word Count for chapters and cleaned up some logics. * I might have card layout working with virtual scroll code. * Some cleanup to hide more system like properties from info bar on series detail page. Fixed some missing time estimate info on storyline chapters. * Fixed a regression on series service when I integrated VolumeTitle. * Refactored read time to the backend. Added WordCount to the volume itself so we don't need to calculate on frontend. When asking to analyze files from a series, force the calculation. * Fixed SeriesDetail api code * Fixed up the code in the drawer to better update list/card mode * Basic infinite scroll implemented, however due to how we are updating the list to render, we are re-rending cards that haven't been touched. * Updated how we render and layout data for infinite scroll on library detail. It's almost there. * Started laying foundation for loading pages backwards. Removed lazy loading of images since we are now using virtual paging. * Hooked in some basic code to allow user to load a prev page with infinite scroll. * Fixed up series detail api and undid the non-lazy loaded images. Changed the router to help with this infinite loading on Firefox issue. * Fixed up some naming issues with Series Detail and added a new test. * This is an infinite scroll without pagination implementation. It is not fully done, but off to a good start. Virtual scroller with jump bar is working pretty well, def needs more polishing and tweaking. There are hacks in this implementation that need to be revisited. * Refactored code so that we don't use any pagination and load all results by default. * Misc code cleanup from build warnings. * Cleaned up some logic for how to display titles in list view. * More title cleanup for specials * Hooked up page layout to user preferences and renamed an existing user pref name to match the dto. * Swapped out everything but storyline with virtual-scroller over CDK * Removed CDK from series detail. * Default value for migration on page layout * Updating card layout for library detail page * fixing height for mobile * Moved scrollbar * Tweaked some styling for layouts when there is no data * Refactored the series cards into their own component to make it re-usable. * More tweaks on series info cards layout and enhanced a few pages with trackby functions. * Removed some dead code * Added download on series detail to actionables to fit in with new scroll strategy. * Fixed language not being updated and sent to the backend for series update. * Fixed a bad migration (if you ran any prior migration in this branch, you need to undo before you use this commit) * Adding sticky tabs * fixed mobile gap on sticky tab * Enhanced the card title for books to show number up front. * Adjusted the gutters on admin dashboard * Removed debug code * Removing duplicate book title * Cleaned up old references to cdk scroller * Implemented a basic jump bar scaling algorithm. Not perfect, but works pretty well. * Code smells Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
544 lines
22 KiB
C#
544 lines
22 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using API.Comparators;
|
|
using API.Data;
|
|
using API.Data.Repositories;
|
|
using API.DTOs;
|
|
using API.DTOs.Reader;
|
|
using API.Entities;
|
|
using API.Extensions;
|
|
using API.SignalR;
|
|
using Kavita.Common;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace API.Services;
|
|
|
|
public interface IReaderService
|
|
{
|
|
Task MarkSeriesAsRead(AppUser user, int seriesId);
|
|
Task MarkSeriesAsUnread(AppUser user, int seriesId);
|
|
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);
|
|
Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
|
|
Task<ChapterDto> GetContinuePoint(int seriesId, int userId);
|
|
Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber);
|
|
Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber);
|
|
HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub);
|
|
}
|
|
|
|
public class ReaderService : IReaderService
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly ILogger<ReaderService> _logger;
|
|
private readonly IEventHub _eventHub;
|
|
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
|
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
|
|
|
public const float MinWordsPerHour = 10260F;
|
|
public const float MaxWordsPerHour = 30000F;
|
|
public const float AvgWordsPerHour = (MaxWordsPerHour + MinWordsPerHour) / 2F;
|
|
public const float MinPagesPerMinute = 3.33F;
|
|
public const float MaxPagesPerMinute = 2.75F;
|
|
public const float AvgPagesPerMinute = (MaxPagesPerMinute + MinPagesPerMinute) / 2F;
|
|
|
|
|
|
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger, IEventHub eventHub)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_logger = logger;
|
|
_eventHub = eventHub;
|
|
}
|
|
|
|
public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId)
|
|
{
|
|
return Parser.Parser.NormalizePath(Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Does not commit. Marks all entities under the series as read.
|
|
/// </summary>
|
|
/// <param name="user"></param>
|
|
/// <param name="seriesId"></param>
|
|
public async Task MarkSeriesAsRead(AppUser user, int seriesId)
|
|
{
|
|
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId);
|
|
user.Progresses ??= new List<AppUserProgress>();
|
|
foreach (var volume in volumes)
|
|
{
|
|
MarkChaptersAsRead(user, seriesId, volume.Chapters);
|
|
}
|
|
|
|
_unitOfWork.UserRepository.Update(user);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Does not commit. Marks all entities under the series as unread.
|
|
/// </summary>
|
|
/// <param name="user"></param>
|
|
/// <param name="seriesId"></param>
|
|
public async Task MarkSeriesAsUnread(AppUser user, int seriesId)
|
|
{
|
|
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(seriesId);
|
|
user.Progresses ??= new List<AppUserProgress>();
|
|
foreach (var volume in volumes)
|
|
{
|
|
MarkChaptersAsUnread(user, seriesId, volume.Chapters);
|
|
}
|
|
|
|
_unitOfWork.UserRepository.Update(user);
|
|
}
|
|
|
|
/// <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) continue;
|
|
|
|
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">Must have Progresses populated</param>
|
|
/// <param name="chapter"></param>
|
|
/// <returns></returns>
|
|
private static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter)
|
|
{
|
|
AppUserProgress userProgress = null;
|
|
|
|
if (user.Progresses == null)
|
|
{
|
|
throw new KavitaException("Progresses must exist on user");
|
|
}
|
|
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>
|
|
/// <param name="progressDto"></param>
|
|
/// <param name="userId"></param>
|
|
/// <returns></returns>
|
|
public async Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId)
|
|
{
|
|
// Don't let user save past total pages.
|
|
progressDto.PageNum = await CapPageToChapter(progressDto.ChapterId, progressDto.PageNum);
|
|
|
|
try
|
|
{
|
|
var userProgress =
|
|
await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId);
|
|
|
|
if (userProgress == null)
|
|
{
|
|
// Create a user object
|
|
var userWithProgress =
|
|
await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress);
|
|
userWithProgress.Progresses ??= new List<AppUserProgress>();
|
|
userWithProgress.Progresses.Add(new AppUserProgress
|
|
{
|
|
PagesRead = progressDto.PageNum,
|
|
VolumeId = progressDto.VolumeId,
|
|
SeriesId = progressDto.SeriesId,
|
|
ChapterId = progressDto.ChapterId,
|
|
BookScrollId = progressDto.BookScrollId,
|
|
LastModified = DateTime.Now
|
|
});
|
|
_unitOfWork.UserRepository.Update(userWithProgress);
|
|
}
|
|
else
|
|
{
|
|
userProgress.PagesRead = progressDto.PageNum;
|
|
userProgress.SeriesId = progressDto.SeriesId;
|
|
userProgress.VolumeId = progressDto.VolumeId;
|
|
userProgress.BookScrollId = progressDto.BookScrollId;
|
|
userProgress.LastModified = DateTime.Now;
|
|
_unitOfWork.AppUserProgressRepository.Update(userProgress);
|
|
}
|
|
|
|
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
|
|
{
|
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
|
await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
|
|
MessageFactory.UserProgressUpdateEvent(userId, user.UserName, progressDto.SeriesId, progressDto.VolumeId, progressDto.ChapterId, progressDto.PageNum));
|
|
return true;
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_logger.LogError(exception, "Could not save progress");
|
|
await _unitOfWork.RollbackAsync();
|
|
}
|
|
|
|
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);
|
|
if (page > totalPages)
|
|
{
|
|
page = totalPages;
|
|
}
|
|
|
|
if (page < 0)
|
|
{
|
|
page = 0;
|
|
}
|
|
|
|
return page;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tries to find the next logical Chapter
|
|
/// </summary>
|
|
/// <example>
|
|
/// V1 → V2 → V3 chapter 0 → V3 chapter 10 → V0 chapter 1 -> V0 chapter 2 -> SP 01 → SP 02
|
|
/// </example>
|
|
/// <param name="seriesId"></param>
|
|
/// <param name="volumeId"></param>
|
|
/// <param name="currentChapterId"></param>
|
|
/// <param name="userId"></param>
|
|
/// <returns>-1 if nothing can be found</returns>
|
|
public async Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
|
|
{
|
|
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
|
|
var currentVolume = volumes.Single(v => v.Id == volumeId);
|
|
var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
|
|
|
|
if (currentVolume.Number == 0)
|
|
{
|
|
// Handle specials by sorting on their Filename aka Range
|
|
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range), currentChapter.Range, dto => dto.Range);
|
|
if (chapterId > 0) return chapterId;
|
|
}
|
|
|
|
foreach (var volume in volumes)
|
|
{
|
|
if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1)
|
|
{
|
|
// Handle Chapters within current Volume
|
|
// In this case, i need 0 first because 0 represents a full volume file.
|
|
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer),
|
|
currentChapter.Range, dto => dto.Range);
|
|
if (chapterId > 0) return chapterId;
|
|
|
|
}
|
|
|
|
if (volume.Number != currentVolume.Number + 1) continue;
|
|
|
|
// Handle Chapters within next Volume
|
|
// ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+
|
|
var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList();
|
|
if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0"))
|
|
{
|
|
// We need to handle an extra check if the current chapter is the last special, as we should return -1
|
|
if (currentChapter.IsSpecial) return -1;
|
|
|
|
return chapters.Last().Id;
|
|
}
|
|
|
|
var firstChapter = chapters.FirstOrDefault();
|
|
if (firstChapter == null) break;
|
|
var isSpecial = firstChapter.IsSpecial || currentChapter.IsSpecial;
|
|
if (isSpecial)
|
|
{
|
|
var chapterId = GetNextChapterId(volume.Chapters.OrderByNatural(x => x.Number),
|
|
currentChapter.Range, dto => dto.Range);
|
|
if (chapterId > 0) return chapterId;
|
|
} else if (double.Parse(firstChapter.Number) > double.Parse(currentChapter.Number)) return firstChapter.Id;
|
|
}
|
|
|
|
// If we are the last volume and we didn't find any next volume, loop back to volume 0 and give the first chapter
|
|
// This has an added problem that it will loop up to the beginning always
|
|
// Should I change this to Max number? volumes.LastOrDefault()?.Number -> volumes.Max(v => v.Number)
|
|
if (currentVolume.Number != 0 && currentVolume.Number == volumes.LastOrDefault()?.Number && volumes.Count > 1)
|
|
{
|
|
var chapterVolume = volumes.FirstOrDefault();
|
|
if (chapterVolume?.Number != 0) return -1;
|
|
var firstChapter = chapterVolume.Chapters.MinBy(x => double.Parse(x.Number), _chapterSortComparer);
|
|
if (firstChapter == null) return -1;
|
|
return firstChapter.Id;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
/// <summary>
|
|
/// Tries to find the prev logical Chapter
|
|
/// </summary>
|
|
/// <example>
|
|
/// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← V0 chapter 1 ← V0 chapter 2 ← SP 01 ← SP 02
|
|
/// </example>
|
|
/// <param name="seriesId"></param>
|
|
/// <param name="volumeId"></param>
|
|
/// <param name="currentChapterId"></param>
|
|
/// <param name="userId"></param>
|
|
/// <returns>-1 if nothing can be found</returns>
|
|
public async Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
|
|
{
|
|
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList();
|
|
var currentVolume = volumes.Single(v => v.Id == volumeId);
|
|
var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId);
|
|
|
|
if (currentVolume.Number == 0)
|
|
{
|
|
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range).Reverse(), currentChapter.Range,
|
|
dto => dto.Range);
|
|
if (chapterId > 0) return chapterId;
|
|
}
|
|
|
|
foreach (var volume in volumes)
|
|
{
|
|
if (volume.Number == currentVolume.Number)
|
|
{
|
|
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(),
|
|
currentChapter.Range, dto => dto.Range);
|
|
if (chapterId > 0) return chapterId;
|
|
}
|
|
if (volume.Number == currentVolume.Number - 1)
|
|
{
|
|
if (currentVolume.Number - 1 == 0) break; // If we have walked all the way to chapter volume, then we should break so logic outside can work
|
|
var lastChapter = volume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
|
|
if (lastChapter == null) return -1;
|
|
return lastChapter.Id;
|
|
}
|
|
}
|
|
|
|
var lastVolume = volumes.MaxBy(v => v.Number);
|
|
if (currentVolume.Number == 0 && currentVolume.Number != lastVolume?.Number && lastVolume?.Chapters.Count > 1)
|
|
{
|
|
var lastChapter = lastVolume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
|
|
if (lastChapter == null) return -1;
|
|
return lastChapter.Id;
|
|
}
|
|
|
|
|
|
return -1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the chapter to continue reading from. If a chapter has progress and not complete, return that. If not, progress in the
|
|
/// ordering (Volumes -> Loose Chapters -> Special) to find next chapter. If all are read, return first in order for series.
|
|
/// </summary>
|
|
/// <param name="seriesId"></param>
|
|
/// <param name="userId"></param>
|
|
/// <returns></returns>
|
|
public async Task<ChapterDto> GetContinuePoint(int seriesId, int userId)
|
|
{
|
|
var progress = (await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId)).ToList();
|
|
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
|
|
|
|
if (progress.Count == 0)
|
|
{
|
|
// I think i need a way to sort volumes last
|
|
return volumes.OrderBy(v => double.Parse(v.Number + string.Empty), _chapterSortComparer).First().Chapters
|
|
.OrderBy(c => float.Parse(c.Number)).First();
|
|
}
|
|
|
|
// Loop through all chapters that are not in volume 0
|
|
var volumeChapters = volumes
|
|
.Where(v => v.Number != 0)
|
|
.SelectMany(v => v.Chapters)
|
|
.OrderBy(c => float.Parse(c.Number))
|
|
.ToList();
|
|
|
|
// If there are any volumes that have progress, return those. If not, move on.
|
|
var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages);
|
|
if (currentlyReadingChapter != null) return currentlyReadingChapter;
|
|
|
|
// Order with volume 0 last so we prefer the natural order
|
|
return FindNextReadingChapter(volumes.OrderBy(v => v.Number, new SortComparerZeroLast()).SelectMany(v => v.Chapters).ToList());
|
|
}
|
|
|
|
private static ChapterDto FindNextReadingChapter(IList<ChapterDto> volumeChapters)
|
|
{
|
|
var chaptersWithProgress = volumeChapters.Where(c => c.PagesRead > 0).ToList();
|
|
if (chaptersWithProgress.Count <= 0) return volumeChapters.First();
|
|
|
|
|
|
var last = chaptersWithProgress.FindLastIndex(c => c.PagesRead > 0);
|
|
if (last + 1 < chaptersWithProgress.Count)
|
|
{
|
|
return chaptersWithProgress.ElementAt(last + 1);
|
|
}
|
|
|
|
var lastChapter = chaptersWithProgress.ElementAt(last);
|
|
if (lastChapter.PagesRead < lastChapter.Pages)
|
|
{
|
|
return chaptersWithProgress.ElementAt(last);
|
|
}
|
|
|
|
// chaptersWithProgress are all read, then we need to get the next chapter that doesn't have progress
|
|
var lastIndexWithProgress = volumeChapters.IndexOf(lastChapter);
|
|
if (lastIndexWithProgress + 1 < volumeChapters.Count)
|
|
{
|
|
return volumeChapters.ElementAt(lastIndexWithProgress + 1);
|
|
}
|
|
|
|
return volumeChapters.First();
|
|
}
|
|
|
|
|
|
private static int GetNextChapterId(IEnumerable<ChapterDto> chapters, string currentChapterNumber, Func<ChapterDto, string> accessor)
|
|
{
|
|
var next = false;
|
|
var chaptersList = chapters.ToList();
|
|
foreach (var chapter in chaptersList)
|
|
{
|
|
if (next)
|
|
{
|
|
return chapter.Id;
|
|
}
|
|
|
|
var chapterNum = accessor(chapter);
|
|
if (currentChapterNumber.Equals(chapterNum)) next = true;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read or Volumes with a single 0 chapter.
|
|
/// </summary>
|
|
/// <param name="user"></param>
|
|
/// <param name="seriesId"></param>
|
|
/// <param name="chapterNumber"></param>
|
|
public async Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber)
|
|
{
|
|
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
|
|
foreach (var volume in volumes.OrderBy(v => v.Number))
|
|
{
|
|
var chapters = volume.Chapters
|
|
.OrderBy(c => float.Parse(c.Number))
|
|
.Where(c => !c.IsSpecial && Parser.Parser.MaxNumberFromRange(c.Range) <= chapterNumber);
|
|
MarkChaptersAsRead(user, volume.SeriesId, chapters);
|
|
}
|
|
}
|
|
|
|
public async Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber)
|
|
{
|
|
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
|
|
foreach (var volume in volumes.OrderBy(v => v.Number).Where(v => v.Number <= volumeNumber && v.Number > 0))
|
|
{
|
|
MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters);
|
|
}
|
|
}
|
|
|
|
public HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub)
|
|
{
|
|
if (isEpub)
|
|
{
|
|
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0);
|
|
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0);
|
|
if (maxHours < minHours)
|
|
{
|
|
return new HourEstimateRangeDto
|
|
{
|
|
MinHours = maxHours,
|
|
MaxHours = minHours,
|
|
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
|
|
};
|
|
}
|
|
return new HourEstimateRangeDto
|
|
{
|
|
MinHours = minHours,
|
|
MaxHours = maxHours,
|
|
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
|
|
};
|
|
}
|
|
|
|
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0);
|
|
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0);
|
|
if (maxHoursPages < minHoursPages)
|
|
{
|
|
return new HourEstimateRangeDto
|
|
{
|
|
MinHours = maxHoursPages,
|
|
MaxHours = minHoursPages,
|
|
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
|
|
};
|
|
}
|
|
|
|
return new HourEstimateRangeDto
|
|
{
|
|
MinHours = minHoursPages,
|
|
MaxHours = maxHoursPages,
|
|
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
|
|
};
|
|
}
|
|
}
|