Estimated time is coded up.

This commit is contained in:
Joseph Milazzo 2025-07-08 15:33:10 -05:00
parent ab6669703d
commit 64ee5ee459
10 changed files with 38 additions and 51 deletions

View File

@ -10,4 +10,8 @@ namespace API.Controllers;
[Authorize] [Authorize]
public class BaseApiController : ControllerBase public class BaseApiController : ControllerBase
{ {
public BaseApiController()
{
}
} }

View File

@ -41,13 +41,12 @@ public class BookController : BaseApiController
/// <param name="chapterId"></param> /// <param name="chapterId"></param>
/// <returns></returns> /// <returns></returns>
[HttpGet("{chapterId}/book-info")] [HttpGet("{chapterId}/book-info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "includeWordCounts"])] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId"])]
public async Task<ActionResult<BookInfoDto>> GetBookInfo(int chapterId, bool includeWordCounts = false) public async Task<ActionResult<BookInfoDto>> GetBookInfo(int chapterId)
{ {
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); if (dto == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
var bookTitle = string.Empty; var bookTitle = string.Empty;
IDictionary<int, int>? pageWordCounts = null;
switch (dto.SeriesFormat) switch (dto.SeriesFormat)
{ {
@ -57,11 +56,6 @@ public class BookController : BaseApiController
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions); using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions);
bookTitle = book.Title; bookTitle = book.Title;
if (includeWordCounts)
{
// TODO: Cache this in temp/chapterId folder to avoid having to process file each time
pageWordCounts = await _bookService.GetWordCountsPerPage(mangaFile.FilePath);
}
break; break;
} }
case MangaFormat.Pdf: case MangaFormat.Pdf:
@ -94,7 +88,6 @@ public class BookController : BaseApiController
LibraryId = dto.LibraryId, LibraryId = dto.LibraryId,
IsSpecial = dto.IsSpecial, IsSpecial = dto.IsSpecial,
Pages = dto.Pages, Pages = dto.Pages,
PageWordCounts = pageWordCounts
}; };

View File

@ -15,6 +15,7 @@ using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Services; using API.Services;
using API.Services.Plus; using API.Services.Plus;
using API.Services.Tasks.Metadata;
using API.SignalR; using API.SignalR;
using Hangfire; using Hangfire;
using Kavita.Common; using Kavita.Common;
@ -222,7 +223,6 @@ public class ReaderController : BaseApiController
/// <param name="chapterId"></param> /// <param name="chapterId"></param>
/// <param name="extractPdf">Should Kavita extract pdf into images. Defaults to false.</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>
/// <param name="includeWordCounts">Include epub word counts per page. Only useful for epub-based reading</param>
/// <returns></returns> /// <returns></returns>
[HttpGet("chapter-info")] [HttpGet("chapter-info")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"])] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions"])]
@ -849,10 +849,18 @@ public class ReaderController : BaseApiController
// Patch in the reading progress // Patch in the reading progress
await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter); await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter);
// TODO: We need to actually use word count from the pages
if (series.Format == MangaFormat.Epub) if (series.Format == MangaFormat.Epub)
{ {
var progressCount = chapter.WordCount; // 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; var wordsLeft = series.WordCount - progressCount;
return _readerService.GetTimeEstimate(wordsLeft, 0, true); return _readerService.GetTimeEstimate(wordsLeft, 0, true);
} }

View File

@ -1,5 +1,4 @@
using System.Collections.Generic; using API.Entities.Enums;
using API.Entities.Enums;
namespace API.DTOs.Reader; namespace API.DTOs.Reader;
@ -16,9 +15,4 @@ public sealed record BookInfoDto : IChapterInfoDto
public int Pages { get; set; } public int Pages { get; set; }
public bool IsSpecial { get; set; } public bool IsSpecial { get; set; }
public string ChapterTitle { get; set; } = default! ; public string ChapterTitle { get; set; } = default! ;
/// <summary>
/// For Epub reader, this will contain Page number -> word count. All other times will be null.
/// </summary>
/// <remarks>This is optionally returned by includeWordCounts</remarks>
public IDictionary<int, int>? PageWordCounts { get; set; }
} }

View File

@ -247,7 +247,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
_unitOfWork.MangaFileRepository.Update(file); _unitOfWork.MangaFileRepository.Update(file);
} }
private async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath) private async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath)
{ {
try try
@ -256,7 +255,8 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
doc.LoadHtml(await bookFile.ReadContentAsync()); doc.LoadHtml(await bookFile.ReadContentAsync());
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0; var characterCount = textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) ?? 0;
return GetWordCount(characterCount);
} }
catch (EpubContentException ex) catch (EpubContentException ex)
{ {
@ -267,4 +267,10 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
} }
} }
public static int GetWordCount(int characterCount)
{
if (characterCount == 0) return 0;
return characterCount / AverageCharactersPerWord;
}
} }

View File

@ -1,7 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core'; import {Pipe, PipeTransform} from '@angular/core';
import {TranslocoService} from "@jsverse/transloco"; import {TranslocoService} from "@jsverse/transloco";
import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
import {DecimalPipe} from "@angular/common";
@Pipe({ @Pipe({
name: 'readTimeLeft', name: 'readTimeLeft',
@ -11,10 +10,10 @@ export class ReadTimeLeftPipe implements PipeTransform {
constructor(private readonly translocoService: TranslocoService) {} constructor(private readonly translocoService: TranslocoService) {}
transform(readingTimeLeft: HourEstimateRange): string { transform(readingTimeLeft: HourEstimateRange, includeLeftLabel = false): string {
const hoursLabel = readingTimeLeft.avgHours > 1 const hoursLabel = readingTimeLeft.avgHours > 1
? this.translocoService.translate('read-time-pipe.hours') ? this.translocoService.translate(`read-time-pipe.hours${includeLeftLabel ? '-left' : ''}`)
: this.translocoService.translate('read-time-pipe.hour'); : this.translocoService.translate(`read-time-pipe.hour${includeLeftLabel ? '-left' : ''}`);
const formattedHours = this.customRound(readingTimeLeft.avgHours); const formattedHours = this.customRound(readingTimeLeft.avgHours);

View File

@ -122,7 +122,7 @@
} }
<div class="book-title col-2 d-none d-sm-block"> <div class="book-title col-3 d-none d-sm-block">
@if(isLoading) { @if(isLoading) {
<!--Just render a blank div here--> <!--Just render a blank div here-->
} @else { } @else {
@ -139,9 +139,9 @@
@let timeLeft = readingTimeLeftResource.value(); @let timeLeft = readingTimeLeftResource.value();
@if (timeLeft) { @if (timeLeft) {
, ,
<span class="time-left" [ngbTooltip]="t('time-left-alt')"> <span class="time-left">
<i class="fa-solid fa-clock" aria-hidden="true"></i> <i class="fa-solid fa-clock" aria-hidden="true"></i>
{{timeLeft! | readTimeLeft }} {{timeLeft! | readTimeLeft:true }}
</span> </span>
} }

View File

@ -246,12 +246,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/ */
nextPageDisabled = false; nextPageDisabled = false;
/**
* Internal property used to capture all the different css properties to render on all elements. This is a cached version that is updated from reader-settings component
*/
//pageStyles = model<PageStyle>(this.readerSettingsService.getDefaultPageStyles());
//pageStyles!: PageStyle;
/** /**
* Offset for drawer and rendering canvas. Fixed to 62px. * Offset for drawer and rendering canvas. Fixed to 62px.
*/ */
@ -270,7 +264,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
request: () => ({ request: () => ({
chapterId: this.chapterId, chapterId: this.chapterId,
seriesId: this.seriesId, seriesId: this.seriesId,
pageNumber: this.pageNum() pageNumber: this.pageNum(),
}), }),
loader: async ({ request }) => { loader: async ({ request }) => {
return this.readerService.getTimeLeftForChapter(this.seriesId, this.chapterId).toPromise(); return this.readerService.getTimeLeftForChapter(this.seriesId, this.chapterId).toPromise();
@ -420,16 +414,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return this.pageNum() === 0 && (currentVirtualPage === 0); return this.pageNum() === 0 && (currentVirtualPage === 0);
} }
// get VerticalBookContentWidth() {
// if (this.layoutMode() !== BookPageLayoutMode.Default && this.writingStyle() !== WritingStyle.Horizontal ) {
// const width = this.getVerticalPageWidth()
// return width + 'px';
// }
// return '';
// }
get PageWidthForPagination() { get PageWidthForPagination() {
if (this.layoutMode() === BookPageLayoutMode.Default && this.writingStyle() === WritingStyle.Vertical && this.horizontalScrollbarNeeded) { if (this.layoutMode() === BookPageLayoutMode.Default && this.writingStyle() === WritingStyle.Vertical && this.horizontalScrollbarNeeded) {

View File

@ -6,8 +6,4 @@ export interface BookInfo {
seriesId: number; seriesId: number;
libraryId: number; libraryId: number;
volumeId: number; volumeId: number;
/**
* Maps the page number to character count. Only available on epub reader.
*/
pageWordCounts: {[key: number]: number};
} }

View File

@ -2785,7 +2785,9 @@
"read-time-pipe": { "read-time-pipe": {
"less-than-hour": "<1 Hour", "less-than-hour": "<1 Hour",
"hour": "Hour", "hour": "Hour",
"hours": "Hours" "hours": "Hours",
"hour-left": "Hour left",
"hours-left": "Hours left"
}, },
"metadata-setting-field-pipe": { "metadata-setting-field-pipe": {