mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-23 15:30:34 -04:00
* Updated swiper and some packages for reported security issues * Fixed reading lists promotion not working * Refactor RenameFileForCopy to use iterative recursion, rather than functional. * Ensured that bookmarks are fetched and ordered by Created date. * Fixed a bug where bookmarks were coming back in the correct order, but due to filenames, would not sort correctly. * Default installs to Debug log level given errors users have and Debug not being too noisy * Added jumpbar to bookmarks page * Now added jumpbar to bookmarks * Refactored some code into pipes and added some debug messaging for prefetcher * Try loading next and prev chapter's first/last page to cache so it renders faster * Updated GetImage to do a bound check on max page. Fixed a critical bug in how manga reader updates image elements src to prefetch/load pages. I was not creating a new reference which broke Angular's ability to update DOM on changes. * Refactored the image setting code to use a single method which tries to use a cached image always. * Refactored code to use getPage which favors cache and simplifies image creation code * Started the work to split the canvas renderer into it's own component * Refactored a lot of common methods into a service for the reader to support the upcoming renderer split * Moved components to nested folder. Refactored more code to streamline image sending to child renderer. Added notes across the code to help streamline flow of data and who owns what. * Swapped out SQLite for Memory, but the one from hangfire. Added DisableConcurrentExecution on ProcessChange to avoid duplication when multiple threads execute at once. * Basic split right to left is working with canvas renderer * Left to right and right to left now work * Fixed a bug where pagesplitoption wasn't being updated when modifying menu * Canvas rendering still has a bug with switching between right to left -> left to right on the re-render, it will choose a bad state. All else works fine with it. * Updated canvas renderer to implement the ImageRenderer interface * Canvas renderer is done * Setup single renderer. Need to figure out how to share CSS between renderers and also share some global stuff, like image height. * Refactored code so that image-container is within the renderers themselves. Still broken in scaling, but working towards a solution. * Added double click to shortcut menu * Moved image containers within the renderers * Pushing up for Robbie * nothing new * Move common css to a single scss file * More css consolidation * Fixed a npe in isWideImage * Refactored page updates to renderers to include max pages. Rewrote most of renderer into observables. * Moved bookmark for second page to double renderer * Started hooking in double renderer renderPage() * Fixed height scaling, but now canvas renderer is broken again * Fixed a bug with canvas renderer not moving to next page. Streamlined the code for getting page amounts from the dfferent renderers * Added double click to bookmark for canvas * Stashing the code and taking a break * Nothing much, buffer is still broken * Got double renderer to render at least one page * Double renderer now has access to 5 images at any time, so it can make appropriate decisions on when to render double pages. * Fixed up double rendererer moving backward page calc * Forward logic seems to be working * Cleaned up dead code after testing * Moved a few loggers in folder watching to trace * Everything seems to work fine, time to do double manga renderer * Moved some css around and added the reverse double component * Only execute renderer's pipes when in the correct mode * Still working on double renderer * Fixed scaling issues on double * Updating double logic - Fixed: Fixed an issue where a second page would render when current page was wide. * Hooked up double renderer * Made changes but not sure if im making progress * double manga fixes * Claned some of robbies code * Fixing last page bug * Library Settings Modal + New Library Settings (#1660) * Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> * Fixed want to read button on series detail not performing the correct action * Started the library settings. Added ability to update a cover image for a library. Updated backup db to also copy reading list (and now library) cover images. * Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library. * Fixed a missing update event in backend when updating a library. * Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid. * Trim library names before you check anything * General code cleanup * Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries. Refactored some code to streamline perf in some flows. * Removed old components replaced with new modal * Code smells Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump versions by dotnet-bump-version. * UX Alignment and bugfixes (#1663) * Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter. Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop. * Removed a bug marker that I just fixed * When generating library covers, make them much smaller as they are only ever icons. * Fixed library settings not showing the correct image. * Fixed a bug where duplicate collection tags could be created. Fixed a bug where collection tag normalized title was being set to uppercase. Redesigned the edit collection tag modal to align with new library settings and provide inline name checks. * Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names. Don't show Continue point on series detail if the whole series is read. * Added some more unit tests around continue point * Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab. * Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting. Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build). * Test GA * Reverted GA and instead do it in the build step. This will just force developers to commit it in. * GA please work * Removed redundant steps from test since build already does it. * Try another GA * Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes. * Fixed env variable * Okay not possible to do secrets in if statement * Fixed the build step to output the openapi.json where it's expected. * Fixed GA (#1664) * Bump versions by dotnet-bump-version. * Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666) * Fixed typeahead and updated manga reader to new layout structure * Fixed book reader fonts lookups * Fixed up some build issues * Fixed a bad import of css image * Some cleanup and rewrote how we log out data. * Renderer can be null on first load when performing some work. * Library Settings Modal + New Library Settings (#1660) * Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> * Fixed want to read button on series detail not performing the correct action * Started the library settings. Added ability to update a cover image for a library. Updated backup db to also copy reading list (and now library) cover images. * Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library. * Fixed a missing update event in backend when updating a library. * Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid. * Trim library names before you check anything * General code cleanup * Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries. Refactored some code to streamline perf in some flows. * Removed old components replaced with new modal * Code smells Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * UX Alignment and bugfixes (#1663) * Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter. Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop. * Removed a bug marker that I just fixed * When generating library covers, make them much smaller as they are only ever icons. * Fixed library settings not showing the correct image. * Fixed a bug where duplicate collection tags could be created. Fixed a bug where collection tag normalized title was being set to uppercase. Redesigned the edit collection tag modal to align with new library settings and provide inline name checks. * Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names. Don't show Continue point on series detail if the whole series is read. * Added some more unit tests around continue point * Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab. * Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting. Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build). * Test GA * Reverted GA and instead do it in the build step. This will just force developers to commit it in. * GA please work * Removed redundant steps from test since build already does it. * Try another GA * Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes. * Fixed env variable * Okay not possible to do secrets in if statement * Fixed the build step to output the openapi.json where it's expected. * Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666) * Post merge cleanup * Again moving the file * Fixed an issue with switching to double renderer and the image not loading for cover image. * Fixed double manga last page repeating twice * Added ability to quickly save a few settings to user preferences from manga reader * Fixed up some success messaging * Single image and canvas could stack, last page on double wouldn't render. * Stashing code, want to work on something else * Suppress a concurrency issue when opening a fresh chapter to read. * Refactored a function into a pipe * Took care of one TODO * Tightened up the logic around single renderer handling fit to screen images. * Added some code to see how long api takes on average. * First pass integration of page dimensions into single renderer and base code * Canvas renderer pass for new page dimensions * On time left, don't use the word left again * Moved the page dimension code into manga service to make it seemless * Hooked in a replacement for image based isWide * Canvas renderer is working again * Double renderer now follows how Komga does it to keep it simple. * Double renderer is working really well so far. * don't use nbsp * Added response caching to file-dimensions and chapter info api * Allow chapter info to send back file dimensions optionally * Fixed an issue with dimensions api locking files on Windows * Refactored all code to use isWidePage * More fixes and cleanup * More double reverse logic * Recently Read stats page will allow you to click the items. Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
635 lines
26 KiB
C#
635 lines
26 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.Entities.Enums;
|
|
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);
|
|
Task MarkChaptersAsRead(AppUser user, int seriesId, IList<Chapter> chapters);
|
|
Task MarkChaptersAsUnread(AppUser user, int seriesId, IList<Chapter> chapters);
|
|
Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
|
|
Task<int> CapPageToChapter(int chapterId, int page);
|
|
int CapPageToChapter(Chapter chapter, 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 = ChapterSortComparer.Default;
|
|
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default;
|
|
|
|
private const float MinWordsPerHour = 10260F;
|
|
private const float MaxWordsPerHour = 30000F;
|
|
public const float AvgWordsPerHour = (MaxWordsPerHour + MinWordsPerHour) / 2F;
|
|
private const float MinPagesPerMinute = 3.33F;
|
|
private 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 Tasks.Scanner.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)
|
|
{
|
|
await MarkChaptersAsRead(user, seriesId, volume.Chapters);
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
await MarkChaptersAsUnread(user, seriesId, volume.Chapters);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit.
|
|
/// </summary>
|
|
/// <remarks>Emits events to the UI for each chapter progress and one for each volume progress</remarks>
|
|
/// <param name="user"></param>
|
|
/// <param name="seriesId"></param>
|
|
/// <param name="chapters"></param>
|
|
public async Task MarkChaptersAsRead(AppUser user, int seriesId, IList<Chapter> chapters)
|
|
{
|
|
var seenVolume = new Dictionary<int, bool>();
|
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
|
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,
|
|
LibraryId = series.LibraryId
|
|
});
|
|
}
|
|
else
|
|
{
|
|
userProgress.PagesRead = chapter.Pages;
|
|
userProgress.SeriesId = seriesId;
|
|
userProgress.VolumeId = chapter.VolumeId;
|
|
}
|
|
|
|
await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
|
|
MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName, seriesId, chapter.VolumeId, chapter.Id, chapter.Pages));
|
|
|
|
// Send out volume events for each distinct volume
|
|
if (!seenVolume.ContainsKey(chapter.VolumeId))
|
|
{
|
|
seenVolume[chapter.VolumeId] = true;
|
|
await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
|
|
MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName, seriesId,
|
|
chapter.VolumeId, 0, chapters.Where(c => c.VolumeId == chapter.VolumeId).Sum(c => c.Pages)));
|
|
}
|
|
|
|
}
|
|
|
|
_unitOfWork.UserRepository.Update(user);
|
|
}
|
|
|
|
/// <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 async Task MarkChaptersAsUnread(AppUser user, int seriesId, IList<Chapter> chapters)
|
|
{
|
|
var seenVolume = new Dictionary<int, bool>();
|
|
foreach (var chapter in chapters)
|
|
{
|
|
var userProgress = GetUserProgressForChapter(user, chapter);
|
|
|
|
if (userProgress == null) continue;
|
|
|
|
userProgress.PagesRead = 0;
|
|
userProgress.SeriesId = seriesId;
|
|
userProgress.VolumeId = chapter.VolumeId;
|
|
|
|
await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
|
|
MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName, userProgress.SeriesId, userProgress.VolumeId, userProgress.ChapterId, 0));
|
|
|
|
// Send out volume events for each distinct volume
|
|
if (!seenVolume.ContainsKey(chapter.VolumeId))
|
|
{
|
|
seenVolume[chapter.VolumeId] = true;
|
|
await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate,
|
|
MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName, seriesId,
|
|
chapter.VolumeId, 0, 0));
|
|
}
|
|
}
|
|
_unitOfWork.UserRepository.Update(user);
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
// TODO: Rewrite this code to just pull user object with progress for that particiular appuserprogress, else create it
|
|
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,
|
|
LibraryId = progressDto.LibraryId,
|
|
BookScrollId = progressDto.BookScrollId
|
|
});
|
|
_unitOfWork.UserRepository.Update(userWithProgress);
|
|
}
|
|
else
|
|
{
|
|
userProgress.PagesRead = progressDto.PageNum;
|
|
userProgress.SeriesId = progressDto.SeriesId;
|
|
userProgress.VolumeId = progressDto.VolumeId;
|
|
userProgress.LibraryId = progressDto.LibraryId;
|
|
userProgress.BookScrollId = progressDto.BookScrollId;
|
|
_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)
|
|
{
|
|
// This can happen when the reader sends 2 events at same time, so 2 threads are inserting and one fails.
|
|
if (exception.Message.StartsWith(
|
|
"The database operation was expected to affect 1 row(s), but actually affected 0 row(s)"))
|
|
return true;
|
|
_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;
|
|
}
|
|
|
|
public int CapPageToChapter(Chapter chapter, int page)
|
|
{
|
|
if (page > chapter.Pages)
|
|
{
|
|
page = chapter.Pages;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
var currentVolumeNumber = float.Parse(currentVolume.Name);
|
|
var next = false;
|
|
foreach (var volume in volumes)
|
|
{
|
|
var volumeNumbersMatch = Math.Abs(float.Parse(volume.Name) - currentVolumeNumber) < 0.00001f;
|
|
if (volumeNumbersMatch && 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;
|
|
next = true;
|
|
continue;
|
|
}
|
|
|
|
if (volumeNumbersMatch)
|
|
{
|
|
next = true;
|
|
continue;
|
|
}
|
|
|
|
if (!next) 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 chapter and next volume is there, we should try to use it (unless it's volume 0)
|
|
else if (double.Parse(firstChapter.Number) == 0) 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;
|
|
}
|
|
|
|
var next = false;
|
|
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;
|
|
next = true; // When the diff between volumes is more than 1, we need to explicitly tell that next volume is our use case
|
|
continue;
|
|
}
|
|
if (next)
|
|
{
|
|
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 && Tasks.Scanner.Parser.Parser.MaxNumberFromRange(c.Range) <= chapterNumber);
|
|
await MarkChaptersAsRead(user, volume.SeriesId, chapters.ToList());
|
|
}
|
|
}
|
|
|
|
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))
|
|
{
|
|
await 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))
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Formats a Chapter name based on the library it's in
|
|
/// </summary>
|
|
/// <param name="libraryType"></param>
|
|
/// <param name="includeHash">For comics only, includes a # which is used for numbering on cards</param>
|
|
/// <param name="includeSpace">Add a space at the end of the string. if includeHash and includeSpace are true, only hash will be at the end.</param>
|
|
/// <returns></returns>
|
|
public static string FormatChapterName(LibraryType libraryType, bool includeHash = false, bool includeSpace = false)
|
|
{
|
|
switch(libraryType)
|
|
{
|
|
case LibraryType.Manga:
|
|
return "Chapter" + (includeSpace ? " " : string.Empty);
|
|
case LibraryType.Comic:
|
|
if (includeHash) {
|
|
return "Issue #";
|
|
}
|
|
return "Issue" + (includeSpace ? " " : string.Empty);
|
|
case LibraryType.Book:
|
|
return "Book" + (includeSpace ? " " : string.Empty);
|
|
default:
|
|
throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null);
|
|
}
|
|
}
|
|
}
|