Epub Annotation System (#4008)

Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo
2025-08-30 14:01:00 -05:00
committed by GitHub
parent 3b883b178f
commit b141613d60
170 changed files with 15837 additions and 3158 deletions
+112 -17
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
@@ -15,6 +16,8 @@ using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Hangfire;
using Kavita.Common;
@@ -41,6 +44,7 @@ public class ReaderController : BaseApiController
private readonly IEventHub _eventHub;
private readonly IScrobblingService _scrobblingService;
private readonly ILocalizationService _localizationService;
private readonly IBookService _bookService;
/// <inheritdoc />
public ReaderController(ICacheService cacheService,
@@ -48,7 +52,8 @@ public class ReaderController : BaseApiController
IReaderService readerService, IBookmarkService bookmarkService,
IAccountService accountService, IEventHub eventHub,
IScrobblingService scrobblingService,
ILocalizationService localizationService)
ILocalizationService localizationService,
IBookService bookService)
{
_cacheService = cacheService;
_unitOfWork = unitOfWork;
@@ -59,6 +64,7 @@ public class ReaderController : BaseApiController
_eventHub = eventHub;
_scrobblingService = scrobblingService;
_localizationService = localizationService;
_bookService = bookService;
}
/// <summary>
@@ -218,11 +224,10 @@ public class ReaderController : BaseApiController
/// <remarks>This is generally the first call when attempting to read to allow pre-generation of assets needed for reading</remarks>
/// <param name="chapterId"></param>
/// <param name="extractPdf">Should Kavita extract pdf into images. Defaults to false.</param>
/// <param name="includeDimensions">Include file dimensions. Only useful for image based reading</param>
/// <param name="includeDimensions">Include file dimensions. Only useful for image-based reading</param>
/// <returns></returns>
[HttpGet("chapter-info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"
])]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"])]
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false)
{
if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore
@@ -720,26 +725,63 @@ public class ReaderController : BaseApiController
[HttpPost("bookmark")]
public async Task<ActionResult> BookmarkPage(BookmarkDto bookmarkDto)
{
// Don't let user save past total pages.
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
if (user == null) return new UnauthorizedResult();
try
{
// Don't let user save past total pages.
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
AppUserIncludes.Bookmarks);
if (user == null) return new UnauthorizedResult();
if (!await _accountService.HasBookmarkPermission(user))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
if (!await _accountService.HasBookmarkPermission(user))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission"));
var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId);
if (chapter == null || chapter.Files.Count == 0)
return BadRequest(await _localizationService.Translate(User.GetUserId(), "cache-file-find"));
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
var path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page);
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
string path;
string? chapterTitle;
if (Parser.IsEpub(chapter.Files.First().Extension!))
{
var cachedFilePath = _cacheService.GetCachedFile(chapter);
path = await _bookService.CopyImageToTempFromBook(chapter.Id, bookmarkDto, cachedFilePath);
var chapterEntity = await _unitOfWork.ChapterRepository.GetChapterAsync(bookmarkDto.ChapterId);
if (chapterEntity == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var toc = await _bookService.GenerateTableOfContents(chapterEntity);
chapterTitle = BookService.GetChapterTitleFromToC(toc, bookmarkDto.Page);
}
else
{
path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page);
chapterTitle = chapter.TitleName;
}
bookmarkDto.ChapterTitle = chapterTitle;
if (string.IsNullOrEmpty(path) || !await _bookmarkService.BookmarkPage(user, bookmarkDto, path))
{
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save"));
}
BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
return Ok();
}
catch (KavitaException ex)
{
_logger.LogError(ex, "There was an exception when trying to create a bookmark");
return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-save"));
BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId));
return Ok();
}
}
/// <summary>
/// Removes a bookmarked page for a Chapter
/// </summary>
@@ -826,6 +868,48 @@ public class ReaderController : BaseApiController
return _readerService.GetTimeEstimate(0, pagesLeft, false);
}
/// <summary>
/// For the current user, returns an estimate on how long it would take to finish reading the chapter.
/// </summary>
/// <remarks>For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases.</remarks>
/// <param name="seriesId"></param>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("time-left-for-chapter")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "chapterId"])]
public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletionForChapter(int seriesId, int chapterId)
{
var userId = User.GetUserId();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
if (series == null || chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
// Patch in the reading progress
await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter);
if (series.Format == MangaFormat.Epub)
{
// Get the word counts for all the pages
var pageCounts = await _bookService.GetWordCountsPerPage(chapter.Files.First().FilePath); // TODO: Cache
if (pageCounts == null) return _readerService.GetTimeEstimate(series.WordCount, 0, true);
// Sum character counts only for pages that have been read
var totalCharactersRead = pageCounts
.Where(kvp => kvp.Key <= chapter.PagesRead)
.Sum(kvp => kvp.Value);
var progressCount = WordCountAnalyzerService.GetWordCount(totalCharactersRead);
var wordsLeft = series.WordCount - progressCount;
return _readerService.GetTimeEstimate(wordsLeft, 0, true);
}
var pagesLeft = chapter.Pages - chapter.PagesRead;
return _readerService.GetTimeEstimate(0, pagesLeft, false);
}
/// <summary>
/// Returns the user's personal table of contents for the given chapter
/// </summary>
@@ -879,6 +963,12 @@ public class ReaderController : BaseApiController
return BadRequest(await _localizationService.Translate(userId, "duplicate-bookmark"));
}
// Look up the chapter this PTOC is associated with to get the chapter title (if there is one)
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId);
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
var toc = await _bookService.GenerateTableOfContents(chapter);
var chapterTitle = BookService.GetChapterTitleFromToC(toc, dto.PageNumber);
_unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent()
{
Title = dto.Title.Trim(),
@@ -887,12 +977,16 @@ public class ReaderController : BaseApiController
SeriesId = dto.SeriesId,
LibraryId = dto.LibraryId,
BookScrollId = dto.BookScrollId,
SelectedText = dto.SelectedText,
ChapterTitle = chapterTitle,
AppUserId = userId
});
await _unitOfWork.CommitAsync();
return Ok();
}
/// <summary>
/// Get all progress events for a given chapter
/// </summary>
@@ -905,4 +999,5 @@ public class ReaderController : BaseApiController
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, userId));
}
}